Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -43,7 +43,18 @@ name: Build App (Preview)
|
||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||
# release; the point of a preview is the build you are testing now.
|
||||
#
|
||||
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||
# (never valid semver) already blocks it independently. (The previous
|
||||
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||
# since build-app.yml's inline mirror already does what it was meant to do
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
@@ -70,12 +81,23 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||
# The bundle version fields never see this — see "Set app version" in
|
||||
# each build job — but it is baked into the binary as
|
||||
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||
# An installed preview otherwise reports the same bare number a
|
||||
# production build would, indistinguishable in the About panel and to
|
||||
# `check_for_updates`. See triple-c#32.
|
||||
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute preview version
|
||||
id: version
|
||||
run: |
|
||||
@@ -86,21 +108,60 @@ jobs:
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number is computed exactly as build-app.yml does it, so a
|
||||
# preview is labelled with the version the release it previews would
|
||||
# carry. This used to be hard-coded `.0`, which made every preview
|
||||
# installer claim to be x.y.0 no matter what it contained.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||
# The patch number must be the same "one past the highest patch
|
||||
# already used" build-app.yml computes for a real release — not a
|
||||
# distance from the latest tag. It used to be
|
||||
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||
# own history section documents as broken for exactly this reason:
|
||||
# it resets to zero on every tag cut, so previews went *backwards*
|
||||
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||
# a preview number from later colliding with a real release's.
|
||||
#
|
||||
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||
# / `-win` suffixed ones a partially-published release can leave
|
||||
# behind) means a preview built right before a release computes the
|
||||
# exact number that release is about to take — e.g. `0.4.13` for
|
||||
# both. That makes the two numerically *equal*, not "preview less
|
||||
# than release" — plain semver ordering does not make a
|
||||
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||
# `check_for_updates` compares against the bare, stripped
|
||||
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||
# check, which relaxes that one comparison to `>=` specifically so
|
||||
# "a release exists at my own number" reads as an update. See
|
||||
# triple-c#32.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||
# HEAD can be a commit a release was already cut from. Without this,
|
||||
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||
# that release — and produce exactly the "preview outranks
|
||||
# production" failure triple-c#32 was filed over, just reintroduced
|
||||
# through the manual-dispatch door instead of the automatic one.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||
SUFFIX="preview.${SHORT_SHA}"
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
@@ -249,6 +310,13 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
env:
|
||||
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||
# no room for a suffix), so this is the only place a preview build
|
||||
# can still tell itself apart from a production one. See
|
||||
# triple-c#32.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
@@ -361,6 +429,9 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
env:
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
@@ -489,6 +560,8 @@ jobs:
|
||||
working-directory: ./app
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
name: Publish AUR Package
|
||||
|
||||
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
|
||||
# for a given release, or the latest one if none is given. Manual dispatch
|
||||
# only — deliberately not triggered by `release` or `push`, for the same
|
||||
# reason sync-release.yml (removed in triple-c#32) never worked safely as an
|
||||
# automatic trigger: this repo's releases are assembled by build-app.yml
|
||||
# across three separate platform jobs, and there is no single automatic event
|
||||
# that fires only once everything (including the Linux .deb this workflow
|
||||
# needs) is actually uploaded. A human deciding "this release is ready, go
|
||||
# package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step)
|
||||
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
|
||||
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
|
||||
# a hand-maintained template with a placeholder version, and every real,
|
||||
# published version lives only in AUR's own git history, which is where a
|
||||
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
|
||||
# a hand-edit made directly in the AUR repo (outside this workflow) is
|
||||
# silently overwritten the next time this runs, since every run renders fresh
|
||||
# from this repo's template rather than starting from AUR's current state.
|
||||
#
|
||||
# ## Required secret
|
||||
#
|
||||
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
|
||||
# account that has already created (or been given co-maintainer access to)
|
||||
# the `triple-c-bin` package. This workflow cannot create that AUR account or
|
||||
# register the key for you — both are manual, one-time steps on
|
||||
# https://aur.archlinux.org. Until this secret exists, every run fails at the
|
||||
# "Push to AUR" step with a clear error rather than silently doing nothing.
|
||||
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
|
||||
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
|
||||
|
||||
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}"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$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"
|
||||
|
||||
old_source = (
|
||||
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
|
||||
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
|
||||
f'Triple-C_${{pkgver}}_amd64.deb"'
|
||||
)
|
||||
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
|
||||
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"
|
||||
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 rm -f "${CID}" >/dev/null
|
||||
|
||||
- name: Push to AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
|
||||
echo "the one-time AUR account setup this needs before it can publish anything." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
# Created with the final mode before any bytes land in it, rather
|
||||
# than a plain redirect followed by chmod, which leaves the key
|
||||
# world-readable for whatever window falls between the two calls.
|
||||
install -m 600 /dev/null ~/.ssh/aur
|
||||
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
# TOFU, not verification — accepted here because pinning AUR's
|
||||
# actual host key needs a value fetched from somewhere trusted
|
||||
# ahead of time, which this workflow doesn't have, and getting a
|
||||
# pinned value wrong fails every future run rather than just this
|
||||
# one. A keyscan failure below surfaces later as an opaque
|
||||
# "Host key verification failed" rather than a clear one here.
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
|
||||
|
||||
git clone "${AUR_REPO}" aur-repo
|
||||
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
|
||||
cd aur-repo
|
||||
git config user.name "Triple-C CI"
|
||||
git config user.email "noreply@triple-c.invalid"
|
||||
git add PKGBUILD .SRCINFO
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change from what's already published on AUR for ${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "triple-c-bin: update to ${VERSION}"
|
||||
# AUR itself uses `master`, which is what a fresh, not-yet-created
|
||||
# AUR package's empty repo advertises on clone — but the *local*
|
||||
# branch name after cloning an empty repo falls back to whatever
|
||||
# this runner's `init.defaultBranch` is if the server sends no
|
||||
# symref, so naming the destination explicitly is what keeps this
|
||||
# working if that default is ever `main` instead of `master`.
|
||||
git push origin HEAD:master
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Secret Scan
|
||||
|
||||
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||
# touching the wrong directory is not a scan.
|
||||
#
|
||||
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||
# from inside a repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||
# but depends on getting the range right across pushes, force-pushes,
|
||||
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||
# takes under half a second on this repository and cannot be evaded by
|
||||
# arranging for the interesting commit to sit outside the window.
|
||||
- name: Scan tracked files for credentials
|
||||
run: sh scripts/scan-secrets.sh --tracked
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit that adds something shaped like a live credential.
|
||||
#
|
||||
# Installed by pointing git at this directory:
|
||||
#
|
||||
# git config core.hooksPath .githooks
|
||||
#
|
||||
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||
# a repo would then be enough to run its code. So this is opt-in on every
|
||||
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||
# hook exists to tell you in one second rather than in five minutes.
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||
+10
@@ -3,3 +3,13 @@ app/dist/
|
||||
app/src-tauri/target/
|
||||
Screenshot*.png
|
||||
code-review.md
|
||||
|
||||
# Windows NTFS alternate-data-stream artifacts, created when files arrive
|
||||
# through the WSL/host bind mount.
|
||||
*:Zone.Identifier
|
||||
|
||||
# Local bug-report screenshots, same spirit as Screenshot*.png above.
|
||||
screenshot_for_fix/
|
||||
|
||||
# Package files pulled in by ad-hoc verification runs.
|
||||
*.deb
|
||||
|
||||
@@ -79,7 +79,62 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
||||
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
|
||||
design — do not move it back into the webview.** The tab browses, views (text and image),
|
||||
renames and creates folders inside the container (`list_container_files`,
|
||||
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
|
||||
copies single files in and out (`upload_files_to_container`, `download_container_file`).
|
||||
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
|
||||
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
|
||||
entirety of its influence — it cannot name a host path as an *input*. The claim stops
|
||||
there and should not be widened: host paths still travel outward in error text, canonical
|
||||
ones included. What is closed is the direction that produced the criticals.
|
||||
That shape is not decoration. Four successive audits found that host filesystem paths
|
||||
crossing IPC were where the criticals lived — a caller-named host destination for
|
||||
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
|
||||
upload reservation that succeeded against a directory and failed forever on any filesystem
|
||||
without hard links. The feature was removed rather than fixed a fifth time, and it came
|
||||
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
|
||||
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
|
||||
would undo the whole point while looking like a simplification.
|
||||
None of the reservation machinery came back with it. There is no destination reservation,
|
||||
no placeholder rollback and no collision marker — the OS save dialog already asks about
|
||||
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
|
||||
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
|
||||
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
|
||||
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
|
||||
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
|
||||
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
||||
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
||||
at all; keep both halves and keep `PaneVisibility`). Backup is
|
||||
`file_commands::download_container_backup`.
|
||||
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||
after canonicalisation.** That includes the general hidden-component rule, which
|
||||
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
|
||||
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
|
||||
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
|
||||
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
|
||||
narrow the rule, because the terminal drop and `download_container_backup` still take
|
||||
their host path over IPC and this predicate is their only boundary.
|
||||
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||
host staging directory were held back for separate hardening and live on
|
||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||
directory is a host-temp disk leak with a gesture attached unless its exit-clear and
|
||||
startup-reap come back with it.
|
||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth.
|
||||
There is deliberately **no Disk panel** here. The disk survey and its reclaim / destroy /
|
||||
compaction surface were held back for separate hardening and live on `hold/disk-and-dragout`;
|
||||
one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it
|
||||
returns whole, `generate_handler!` entries and typed confirmations included. The *prevention*
|
||||
half stayed and is not disk-panel code: the pre-commit scrub in `docker/container.rs`, capped
|
||||
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
|
||||
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
|
||||
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||
@@ -203,7 +258,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
### Container (`container/`)
|
||||
|
||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||
libraries a browser links against (see below)
|
||||
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||
@@ -287,15 +343,75 @@ container is created once by a very long function where a dropped capability is
|
||||
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. When the host has no such device the failure lands at *creation* — the
|
||||
project simply won't start — so `explain_create_failure()` rewrites that one error to name the
|
||||
switch and the Docker-Desktop-VM-vs-your-machine distinction. Do not let it degrade to a raw
|
||||
bollard string.
|
||||
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
|
||||
|
||||
@@ -409,6 +525,33 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
`models/project.rs` for anything that should default to true.
|
||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||
|
||||
## Secrets
|
||||
|
||||
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||
for `container/**`.
|
||||
|
||||
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||
catches the real incident (`9b2f4fe`) when replayed.
|
||||
|
||||
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||
for it.
|
||||
|
||||
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
+190
-32
@@ -128,8 +128,11 @@ Anthropic-backend project uses that token without its own login. See
|
||||
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||
3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below.
|
||||
|
||||
> If the login hangs after the browser step, the callback could not reach the container. Enable the
|
||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project.
|
||||
> If the login hangs after the browser step, the callback could not reach the container. Either
|
||||
> click **In container** on the toast instead of **Open** — the callback then never has to leave the
|
||||
> container at all — or turn on the
|
||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) in the project's
|
||||
> **Config → Runtime** section.
|
||||
|
||||
**AWS Bedrock:**
|
||||
|
||||
@@ -225,7 +228,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -348,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -477,21 +480,82 @@ When enabled, the container is given the three things a VPN client needs to buil
|
||||
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**.
|
||||
|
||||
Without it, a client such as PIA, WireGuard, OpenVPN or Tailscale 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.
|
||||
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:
|
||||
|
||||
- `NET_ADMIN` applies to the container's **own** network namespace. It confers no authority over
|
||||
the host's interfaces or over any other container. It does mean anything running in the
|
||||
container can reconfigure that namespace, which is why it is opt-in.
|
||||
- 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 fails to create with an
|
||||
error naming `/dev/net/tun` and pointing back at this setting.
|
||||
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.
|
||||
@@ -551,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||
real choice — it overrides a global On, which a project could not previously do.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||
> when the container starts. Changing one stops and recreates the container.
|
||||
>
|
||||
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||
> Overview tab first. The other switches work on any image.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
@@ -728,6 +801,19 @@ web server they started on `localhost`. `claude login`, `aws sso login` and Conc
|
||||
|
||||
The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**.
|
||||
|
||||
### Where the switch is
|
||||
|
||||
Project Home → **Config** → **Runtime** → **Auth bridge**.
|
||||
|
||||
Unlike the rest of that tab, it is **not** greyed out while the container is running — it is a
|
||||
host-side feature that recreates nothing, and the moment you want it is usually the moment a login
|
||||
is already hanging in a running container. Switch it on, then retry the login.
|
||||
|
||||
Beside the switch is its live state: **Off**, **Watching** (on, nothing to bridge yet — normal,
|
||||
there is only something to bridge while a login is waiting), **Bridging *n* ports**, **IPv4 only**,
|
||||
or **Port conflict** with the port and the reason. A conflict means the host port was already taken
|
||||
and the callback will not arrive; free the port, or use **In container** instead.
|
||||
|
||||
### What it does
|
||||
|
||||
- Every couple of seconds it looks inside the container for programs listening on the container's
|
||||
@@ -1084,14 +1170,61 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
||||
- **Download** any file to your host machine via the **Download** button on each file entry
|
||||
- **Upload file** from your host into the current container directory
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
viewer
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates.
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
|
||||
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
|
||||
way are judged.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1137,10 +1270,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks (In the Container)
|
||||
### Creating Tasks
|
||||
|
||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
||||
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||
form for the name, the schedule and the prompt.
|
||||
|
||||
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
@@ -1232,9 +1369,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
||||
| **Ctrl+Shift+V** | Paste |
|
||||
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
||||
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
||||
| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it |
|
||||
| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down |
|
||||
|
||||
Everything else goes straight through to the program running in the container.
|
||||
|
||||
> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs
|
||||
> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is
|
||||
> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and
|
||||
> Shift+Enter there submits the line as it always has.
|
||||
>
|
||||
> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+**
|
||||
> key beside **Enter** on the mobile key row for devices with no Shift.
|
||||
|
||||
---
|
||||
|
||||
## What's Inside the Container
|
||||
@@ -1251,6 +1398,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| ruff | Latest | Python linter/formatter |
|
||||
| Rust | Stable | Rust development (via rustup) |
|
||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||
| iproute2, WireGuard tools, iptables | Latest | Building a tunnel (when VPN Support is enabled) |
|
||||
| git | Latest | Version control |
|
||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||
| AWS CLI | v2 | AWS services and Bedrock |
|
||||
@@ -1340,8 +1488,18 @@ your machine (anything that isn't `http`/`https`).
|
||||
|
||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
||||
*container's*. Enable the
|
||||
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
|
||||
*container's*.
|
||||
|
||||
Two ways out, in order of least effort:
|
||||
|
||||
1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**.
|
||||
The page opens in a browser *inside* the container, so the callback never has to cross to the
|
||||
host. This needs no auth bridge — only a running container with Playwright installed (Project
|
||||
Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button.
|
||||
2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home →
|
||||
**Config** → **Runtime** → **Auth bridge** — and try again. It can be switched on while the
|
||||
container is running. Check the indicator beside it: **Port conflict** means the host port was
|
||||
already taken and the callback still will not arrive.
|
||||
|
||||
For Claude specifically, the simpler answer is usually
|
||||
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
||||
|
||||
@@ -24,7 +24,7 @@ This file is the architectural tour: what each subsystem is and why it works the
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
@@ -76,8 +76,21 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
|
||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||
why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||
`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
|
||||
|
||||
@@ -92,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, 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
|
||||
@@ -429,6 +442,39 @@ per project.
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
### Host File Transfers
|
||||
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||
canonical ones included, so this closes the inbound direction and not both.
|
||||
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||
save dialog parses its name box as a path, and a container can name a file
|
||||
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||
cannot be a path on any platform this ships to.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
## Inside a Project
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
@@ -500,12 +546,12 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
|
||||
|
||||
### Frontend — settings, terminal and hooks
|
||||
|
||||
@@ -523,7 +569,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
@@ -534,7 +580,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
@@ -548,7 +594,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | 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/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
|
||||
+22
-7
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
|
||||
|
||||
## Current coverage (v0.3.0)
|
||||
|
||||
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
|
||||
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
|
||||
|
||||
| Key | Surfaced as |
|
||||
|---|---|
|
||||
| `tui` | TUI Mode select (`fullscreen`) |
|
||||
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
||||
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
||||
| `focusMode` | Focus Mode toggle |
|
||||
| `showThinkingSummaries` | Thinking Summaries toggle |
|
||||
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
|
||||
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
|
||||
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||
|
||||
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
|
||||
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
|
||||
merges into it, so a key merely omitted when its control goes off left the previous
|
||||
on-value in place forever.
|
||||
|
||||
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||
`CLAUDE_CODE_*` vars via the Env Vars modal.
|
||||
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
|
||||
create *including* their off value, because `docker commit` bakes a container's env into
|
||||
the snapshot image: a value written once would otherwise ride that snapshot into every
|
||||
future container. That also makes them Triple-C's to own, so all four are reserved names
|
||||
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
|
||||
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
|
||||
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
|
||||
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
|
||||
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
|
||||
choice it never asked about.
|
||||
|
||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||
|
||||
+15
-10
@@ -412,13 +412,18 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
|
||||
│
|
||||
├── packaging/
|
||||
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
|
||||
│ ├── PKGBUILD
|
||||
│ └── README.md
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -436,7 +441,7 @@ triple-c/
|
||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File browser operations
|
||||
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||
@@ -464,7 +469,7 @@ triple-c/
|
||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||
@@ -504,7 +509,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
Generated
-10
@@ -11,7 +11,6 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
@@ -2001,15 +2000,6 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
||||
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
+2
-2
@@ -9,13 +9,13 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
|
||||
Generated
+5
-22
@@ -1135,7 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3394,7 +3394,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3743,7 +3743,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4654,22 +4654,6 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.0"
|
||||
@@ -4782,7 +4766,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5187,7 +5171,6 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
@@ -5689,7 +5672,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2473,180 +2473,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
||||
"type": "string",
|
||||
"const": "store:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-clear",
|
||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-delete",
|
||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-entries",
|
||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get",
|
||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get-store",
|
||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-has",
|
||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-keys",
|
||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-length",
|
||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-load",
|
||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reload",
|
||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reset",
|
||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-set",
|
||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-values",
|
||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-clear",
|
||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-delete",
|
||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-entries",
|
||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get",
|
||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get-store",
|
||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-has",
|
||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-keys",
|
||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-length",
|
||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-load",
|
||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reload",
|
||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reset",
|
||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-set",
|
||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2473,180 +2473,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
||||
"type": "string",
|
||||
"const": "store:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-clear",
|
||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-delete",
|
||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-entries",
|
||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get",
|
||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get-store",
|
||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-has",
|
||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-keys",
|
||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-length",
|
||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-load",
|
||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reload",
|
||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reset",
|
||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-set",
|
||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-values",
|
||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-clear",
|
||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-delete",
|
||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-entries",
|
||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get",
|
||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get-store",
|
||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-has",
|
||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-keys",
|
||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-length",
|
||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-load",
|
||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reload",
|
||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reset",
|
||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-set",
|
||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -82,6 +82,10 @@ pub struct BridgedPort {
|
||||
pub family: PortFamily,
|
||||
/// RFC 3339 timestamp of when the host listener was bound.
|
||||
pub bridged_at: String,
|
||||
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||
/// port still works, but not for a client that insists on `::1` — see
|
||||
/// [`tunnel::PortForward::ipv6_warning`].
|
||||
pub ipv6_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// A loopback listener that was discovered but could not be bridged.
|
||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
||||
port: f.port,
|
||||
family: f.family,
|
||||
bridged_at: f.bridged_at.clone(),
|
||||
ipv6_warning: f.ipv6_warning.clone(),
|
||||
})
|
||||
.collect(),
|
||||
conflicts: self
|
||||
@@ -329,7 +334,10 @@ async fn poll_loop(
|
||||
Ok(text) => {
|
||||
exec_failures = 0;
|
||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||
let skip = skipped_ports(&project);
|
||||
// Re-read every tick: a project can gain a port mapping and the
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
@@ -372,24 +380,101 @@ async fn poll_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
/// Every port this project's bridge must not take.
|
||||
///
|
||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||
/// loopback listeners another feature owns and exposes on its own,
|
||||
/// authenticated terms.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||
/// enumerated here. Four sources:
|
||||
///
|
||||
/// 1. **This project's own published ports** — a container port that Docker
|
||||
/// already publishes has a host-side path, and the mapping's host port is a
|
||||
/// binding we must not fight over.
|
||||
/// 2. **Every other project's published host ports.** The container names the
|
||||
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||
/// the host end of another project's mapping is reserved: its container end
|
||||
/// is a number inside a different network namespace and means nothing here.
|
||||
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||
/// and the web terminal. All three are off by default and bind on demand, so
|
||||
/// first-come would win: a container that binds container-loopback 4000
|
||||
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||
/// anything on the host dialling 4000 — including *other project
|
||||
/// containers*, which reach the gateway by host address — is talking to the
|
||||
/// squatting container instead. The web terminal is the worst of the three,
|
||||
/// because its access token travels in the URL query. Both the *configured*
|
||||
/// port and the shipped default are reserved: the configured one is what the
|
||||
/// service will bind next, and the default is what it falls back to for a
|
||||
/// fresh profile or a settings file that failed to parse.
|
||||
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||
/// terms.
|
||||
///
|
||||
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||
/// policy is unit-testable without a store, a container or an app handle.
|
||||
fn skipped_ports(
|
||||
project: &crate::models::Project,
|
||||
all_projects: &[crate::models::Project],
|
||||
settings: &crate::models::AppSettings,
|
||||
) -> HashSet<u16> {
|
||||
let mut skip: HashSet<u16> = project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect();
|
||||
|
||||
// Other projects: host end only.
|
||||
skip.extend(
|
||||
all_projects
|
||||
.iter()
|
||||
.filter(|p| p.id != project.id)
|
||||
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||
);
|
||||
|
||||
skip.extend(app_service_host_ports(settings));
|
||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||
skip
|
||||
}
|
||||
|
||||
/// Current app settings, or defaults if the state is not reachable.
|
||||
///
|
||||
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||
/// worse than one that falls back to the shipped port numbers — which are what
|
||||
/// the services use anyway until someone changes them.
|
||||
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||
use tauri::Manager;
|
||||
app.try_state::<crate::AppState>()
|
||||
.map(|state| state.settings_store.get())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Host ports this app's own sibling services bind, configured value and
|
||||
/// shipped default alike.
|
||||
///
|
||||
/// Read off the settings models rather than restated as literals here: a
|
||||
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||
/// and the failure mode of drift is a reservation that no longer covers the
|
||||
/// service it was written for.
|
||||
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||
use crate::models::{SttSettings, WebTerminalSettings};
|
||||
|
||||
vec![
|
||||
// LiteLLM gateway (`docker/gateway.rs`).
|
||||
settings.gateway.port,
|
||||
crate::models::default_gateway_port(),
|
||||
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||
settings.stt.port,
|
||||
SttSettings::default().port,
|
||||
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||
// its access token is in the URL query.
|
||||
settings.web_terminal.port,
|
||||
WebTerminalSettings::default().port,
|
||||
]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -586,7 +671,7 @@ async fn emit_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{PortMapping, Project, ProjectPath};
|
||||
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||
let mut p = Project::new(
|
||||
@@ -607,9 +692,14 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// The common case: one project, no siblings, stock settings.
|
||||
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_already_published_by_docker_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
assert!(skip.contains(&3000));
|
||||
// Both ends of an asymmetric mapping are off limits: the container port
|
||||
// is already reachable, and the host port is Docker's binding.
|
||||
@@ -619,20 +709,96 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||
let settings = AppSettings::default();
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skip_for(&project);
|
||||
|
||||
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||
expected.extend(RESERVED_HOST_PORTS);
|
||||
expected.extend(app_service_host_ports(&settings));
|
||||
assert_eq!(skip, expected);
|
||||
|
||||
// The ranges and the service ports are disjoint, so nothing above is
|
||||
// accidentally counting the same port twice.
|
||||
assert_eq!(
|
||||
skip.len(),
|
||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||
RESERVED_CONTAINER_PORTS.clone().count()
|
||||
+ RESERVED_HOST_PORTS.clone().count()
|
||||
+ 3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_apps_own_host_services_are_never_taken() {
|
||||
// The bug this guards: the reserved set used to cover only the
|
||||
// browser-view ranges and this project's own mappings, so a container
|
||||
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||
// door before they could bind it.
|
||||
let settings = AppSettings::default();
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
|
||||
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||
|
||||
// The shipped defaults, spelled out once so a change to any of them is
|
||||
// a change to this assertion and not a silent narrowing.
|
||||
assert!(skip.contains(&4000));
|
||||
assert!(skip.contains(&9876));
|
||||
assert!(skip.contains(&7681));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.gateway.port = 4321;
|
||||
settings.stt.port = 9000;
|
||||
settings.web_terminal.port = 8443;
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||
|
||||
for port in [4321, 9000, 8443] {
|
||||
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||
}
|
||||
// The default stays reserved too: it is what the service falls back to
|
||||
// for a fresh profile or an unparseable settings file, so leaving it
|
||||
// open is leaving the same squat available one restart later.
|
||||
for port in [4000, 9876, 7681] {
|
||||
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_projects_published_host_port_is_not_stolen() {
|
||||
// The container names the *host* port. Without this, project A's
|
||||
// container listening on 8080 takes the host 8080 that project B
|
||||
// publishes on — the bridge wins the race whenever B's container is not
|
||||
// running yet.
|
||||
let mine = project_with_mappings(vec![]);
|
||||
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||
theirs.id = format!("{}-other", mine.id);
|
||||
|
||||
let skip = skipped_ports(
|
||||
&mine,
|
||||
&[mine.clone(), theirs.clone()],
|
||||
&AppSettings::default(),
|
||||
);
|
||||
assert!(skip.contains(&8080), "another project's host port");
|
||||
// …but not the other project's *container* port: that number lives in a
|
||||
// different network namespace and means nothing on this host, and
|
||||
// reserving it would refuse a legitimate login callback for no reason.
|
||||
assert!(!skip.contains(&3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_host_ports_are_never_taken() {
|
||||
// The bridge binds *host* ports chosen by the container, so without
|
||||
// this it can take the port the browser-view proxy will want later —
|
||||
// that pane binds on demand, so first-come would win.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_HOST_PORTS {
|
||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||
}
|
||||
@@ -688,14 +854,14 @@ mod tests {
|
||||
// Mirroring these would publish an ungated second door to the
|
||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||
// token-checking listener.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_CONTAINER_PORTS {
|
||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||
|
||||
// Reservations coexist with Docker's own published ports.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||
assert!(skip.contains(&3000));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,48 @@
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
//!
|
||||
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||
//! nothing in that chain can be taught to present a token — so there is no path
|
||||
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||
//! the container-side listener. That includes **any web page the user has open**,
|
||||
//! which can port-scan loopback from script.
|
||||
//!
|
||||
//! Two things narrow that, and neither is a substitute for the other:
|
||||
//!
|
||||
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||
//! ports while its container is running.
|
||||
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||
//! what an OAuth redirect is.
|
||||
//!
|
||||
//! The residual risk, stated plainly rather than papered over: a client that
|
||||
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||
//! probe must all still work) but also any browser predating fetch metadata
|
||||
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||
//! port: if the container has something more interesting than a throwaway OAuth
|
||||
//! listener on loopback, a same-machine caller reaches it.
|
||||
//!
|
||||
//! ## Bounds
|
||||
//!
|
||||
//! Every accepted connection costs a `docker exec`, and the number of
|
||||
//! connections is decided by whoever can reach the port. So each forward caps
|
||||
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||
//! exec is created), and drops a connection the container has gone quiet on
|
||||
//! ([`IDLE_TIMEOUT`]).
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::StreamExt;
|
||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
||||
/// only needs to not be pathological.
|
||||
const PUMP_BUF: usize = 16 * 1024;
|
||||
|
||||
/// Concurrent connections one forwarded port will carry.
|
||||
///
|
||||
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||
/// connection, occasionally a handful; this is generous for that and still a
|
||||
/// bound the engine will not notice.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
/// How long an accepted connection has to send its first byte before it is
|
||||
/// dropped, *without* a `docker exec` ever being created for it.
|
||||
///
|
||||
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||
/// that is the trade, and it is the only protocol shape affected.
|
||||
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How long a live connection may go with nothing coming back from the container
|
||||
/// before it is torn down. Generous, because a bridged port is not always a
|
||||
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||
/// exec forever.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||
const MAX_HEAD: usize = 32 * 1024;
|
||||
|
||||
/// How long the rest of a request head has, once the first line has identified
|
||||
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||
/// child running.
|
||||
struct AbortOnDrop(JoinHandle<()>);
|
||||
@@ -52,6 +124,15 @@ pub struct PortForward {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
pub bridged_at: String,
|
||||
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||
///
|
||||
/// A half-bound forward is the one failure mode that looks like a success:
|
||||
/// the status says the port is bridged, and a browser that resolves
|
||||
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||
/// so, rather than being logged at debug where nobody sees it.
|
||||
pub ipv6_warning: Option<String>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -86,18 +167,33 @@ impl PortForward {
|
||||
// first, so a v4-only host listener would miss those callbacks. This is
|
||||
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => Some(l),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let (v6, ipv6_warning) =
|
||||
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => (Some(l), None),
|
||||
Err(e) => {
|
||||
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||
// not about whether to say anything: on a host where
|
||||
// `localhost` resolves to `::1` and the client does not
|
||||
// fall back to IPv4, the callback is refused while the
|
||||
// bridge reports itself healthy — a silent failure with no
|
||||
// thread back to this line.
|
||||
log::warn!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
|
||||
a client that resolves localhost to ::1 without falling back will not reach it",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||
localhost to ::1 without falling back will not reach this port.",
|
||||
port, e
|
||||
)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
@@ -106,6 +202,7 @@ impl PortForward {
|
||||
port,
|
||||
family,
|
||||
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||
ipv6_warning,
|
||||
task,
|
||||
})
|
||||
}
|
||||
@@ -142,6 +239,22 @@ async fn accept_loop(
|
||||
|
||||
match accepted {
|
||||
Ok((stream, peer)) => {
|
||||
// Reap first, so the cap counts *live* connections rather than
|
||||
// every one this listener has ever accepted.
|
||||
while conns.try_join_next().is_some() {}
|
||||
if conns.len() >= MAX_CONNECTIONS {
|
||||
// Dropping the stream closes it. Better than queueing: the
|
||||
// client side is whatever can dial loopback, so a queue is
|
||||
// just a slower way to run out of execs.
|
||||
log::warn!(
|
||||
"Auth bridge: refusing connection from {} to bridged port {} — \
|
||||
{} concurrent connections already open on it",
|
||||
peer,
|
||||
port,
|
||||
MAX_CONNECTIONS
|
||||
);
|
||||
continue;
|
||||
}
|
||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
@@ -170,9 +283,224 @@ async fn accept_optional(
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry one accepted host connection into the container over `socat`.
|
||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
||||
/// Carry one accepted host connection into the container over `socat`, after
|
||||
/// deciding it is not a web page reaching into loopback.
|
||||
///
|
||||
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||
/// reaches the container at all — not even a `docker exec`.
|
||||
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||
let head = match read_leading_bytes(&mut stream).await {
|
||||
Ok(head) => head,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: dropping connection to bridged port {} before forwarding: {}",
|
||||
port,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let LeadingBytes::HttpRequest { buffer, head_len } = &head {
|
||||
// Authorize against the head slice only. Parsing past the blank line is
|
||||
// how a request *body* gets read as headers — a cross-site `fetch` with
|
||||
// a `text/plain` body is not preflighted, so it can put any line it
|
||||
// likes in there.
|
||||
let head_text = String::from_utf8_lossy(&buffer[..*head_len]);
|
||||
if web_request_verdict(&head_text) == Verdict::RefuseCrossSite {
|
||||
log::warn!(
|
||||
"Auth bridge: refused a cross-site sub-resource request to bridged port {} — \
|
||||
a web page, not a login redirect",
|
||||
port
|
||||
);
|
||||
let _ = refuse(&mut stream).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
enum LeadingBytes {
|
||||
/// An HTTP request whose head we have in full. `head_len` is one past the
|
||||
/// blank line; `buffer` may hold pipelined body bytes beyond it.
|
||||
HttpRequest { buffer: Vec<u8>, head_len: usize },
|
||||
/// Not HTTP, or HTTP we gave up on reading. Forwarded verbatim, ungated.
|
||||
Opaque(Vec<u8>),
|
||||
}
|
||||
|
||||
impl LeadingBytes {
|
||||
fn into_buffer(self) -> Vec<u8> {
|
||||
match self {
|
||||
LeadingBytes::HttpRequest { buffer, .. } => buffer,
|
||||
LeadingBytes::Opaque(buffer) => buffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read just enough of the connection to classify it, without consuming
|
||||
/// anything the caller cannot replay.
|
||||
///
|
||||
/// Bails out to [`LeadingBytes::Opaque`] the moment the first line proves this
|
||||
/// is not HTTP, so a non-HTTP protocol pays one line of latency and no more.
|
||||
/// The only hard failure is silence: a client that sends nothing within
|
||||
/// [`FIRST_BYTE_TIMEOUT`] is dropped before an exec is spent on it.
|
||||
async fn read_leading_bytes(stream: &mut TcpStream) -> Result<LeadingBytes, String> {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||
let mut chunk = [0u8; 1024];
|
||||
let mut deadline = tokio::time::Instant::now() + FIRST_BYTE_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let n = match tokio::time::timeout_at(deadline, stream.read(&mut chunk)).await {
|
||||
Ok(Ok(0)) if buf.is_empty() => {
|
||||
return Err("closed before sending anything".to_string())
|
||||
}
|
||||
// A half-close after some bytes is legitimate; forward what we have.
|
||||
Ok(Ok(0)) => return Ok(LeadingBytes::Opaque(buf)),
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(e)) => return Err(format!("read failed: {}", e)),
|
||||
Err(_) if buf.is_empty() => {
|
||||
return Err(format!(
|
||||
"sent nothing within {}s",
|
||||
FIRST_BYTE_TIMEOUT.as_secs()
|
||||
))
|
||||
}
|
||||
// Bytes arrived but the head never finished. Fail open: this is a
|
||||
// gate on top of the bridge, not the bridge's reason to exist.
|
||||
Err(_) => return Ok(LeadingBytes::Opaque(buf)),
|
||||
};
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
|
||||
// Once the first line is complete we know whether to keep reading.
|
||||
if let Some(eol) = buf.iter().position(|b| *b == b'\n') {
|
||||
if !is_http_request_line(&buf[..eol]) {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
deadline = deadline.max(tokio::time::Instant::now() + HEAD_TIMEOUT);
|
||||
} else if buf.len() > MAX_HEAD {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
|
||||
if let Some(head_len) = find_head_end(&buf) {
|
||||
return Ok(LeadingBytes::HttpRequest {
|
||||
buffer: buf,
|
||||
head_len,
|
||||
});
|
||||
}
|
||||
if buf.len() > MAX_HEAD {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a first line looks like `METHOD target HTTP/1.x`.
|
||||
fn is_http_request_line(line: &[u8]) -> bool {
|
||||
let line = String::from_utf8_lossy(line);
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
let mut parts = line.split(' ');
|
||||
let (Some(method), Some(target), Some(version), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!method.is_empty()
|
||||
&& method.chars().all(|c| c.is_ascii_uppercase())
|
||||
&& !target.is_empty()
|
||||
&& (version == "HTTP/1.1" || version == "HTTP/1.0")
|
||||
}
|
||||
|
||||
/// Index just past the blank line terminating an HTTP head, if it has arrived.
|
||||
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||
buf.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.map(|i| i + 4)
|
||||
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||
}
|
||||
|
||||
/// Tell a refused caller why, then close. Plain text and `Connection: close` —
|
||||
/// there is no session here to keep alive.
|
||||
async fn refuse(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||
const BODY: &str = "This port is bridged from a container by Triple-C for a sign-in \
|
||||
callback. It is not an API for web pages to call.\n";
|
||||
let response = format!(
|
||||
"HTTP/1.1 403 Forbidden\r\n\
|
||||
Content-Type: text/plain; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Cache-Control: no-store\r\n\
|
||||
Connection: close\r\n\r\n{}",
|
||||
BODY.len(),
|
||||
BODY
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.shutdown().await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The gate — pure, so it can be tested without sockets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Verdict {
|
||||
/// Forward it. Either it is not a browser, or the browser says this is a
|
||||
/// navigation or a same-origin request.
|
||||
Allow,
|
||||
/// Fetch metadata says a document on another site pulled this in as a
|
||||
/// sub-resource. No login flow looks like that.
|
||||
RefuseCrossSite,
|
||||
}
|
||||
|
||||
/// Decide whether an HTTP request head arriving on a bridged port may be
|
||||
/// forwarded into the container.
|
||||
///
|
||||
/// Deliberately fail-open — see the module docs for exactly what that leaves
|
||||
/// uncovered. The only refusal is the case with no innocent reading:
|
||||
/// `Sec-Fetch-Site` says another site, and `Sec-Fetch-Mode` says this is not a
|
||||
/// navigation. `Sec-Fetch-*` are forbidden header names, so page script cannot
|
||||
/// set or clear them.
|
||||
pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||
// Skip the request line.
|
||||
if lines.next().is_none() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
let mut site: Option<&str> = None;
|
||||
let mut mode: Option<&str> = None;
|
||||
for line in lines {
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
// A duplicate of either header is header smuggling, not a client.
|
||||
// Refuse rather than pick a winner: last-occurrence-wins is what
|
||||
// turns a smuggling primitive into a bypass.
|
||||
"sec-fetch-site" if site.is_some() => return Verdict::RefuseCrossSite,
|
||||
"sec-fetch-mode" if mode.is_some() => return Verdict::RefuseCrossSite,
|
||||
"sec-fetch-site" => site = Some(value),
|
||||
"sec-fetch-mode" => mode = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(site) = site else {
|
||||
// No fetch metadata: a CLI, `curl`, or a browser old enough not to send
|
||||
// it. Not something this gate can judge.
|
||||
return Verdict::Allow;
|
||||
};
|
||||
if site.eq_ignore_ascii_case("same-origin") || site.eq_ignore_ascii_case("none") {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
// `navigate` is precisely the OAuth redirect: the provider sends the browser
|
||||
// to `http://localhost:<port>/callback`, cross-site, as a document load.
|
||||
// Refusing it would refuse the feature.
|
||||
if mode.is_none_or(|m| m.eq_ignore_ascii_case("navigate")) {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
Verdict::RefuseCrossSite
|
||||
}
|
||||
|
||||
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||
@@ -225,21 +553,36 @@ pub async fn tunnel_connection_with_prelude(
|
||||
}
|
||||
let mut buf = vec![0u8; PUMP_BUF];
|
||||
loop {
|
||||
match host_rx.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
// Idle-bounded. Without this a client that connects, sends a
|
||||
// request and then never speaks or closes holds the exec open for
|
||||
// as long as the container runs.
|
||||
match tokio::time::timeout(IDLE_TIMEOUT, host_rx.read(&mut buf)).await {
|
||||
Ok(Ok(0)) | Err(_) => break,
|
||||
Ok(Ok(n)) => {
|
||||
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
Ok(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Container → host. This direction is authoritative: when the exec's output
|
||||
// stream ends, socat has exited and the connection is over.
|
||||
while let Some(chunk) = output.next().await {
|
||||
// stream ends, socat has exited and the connection is over. It is also the
|
||||
// one that decides the connection is dead: nothing back from the container
|
||||
// for `IDLE_TIMEOUT` tears the whole thing down, exec included.
|
||||
while let Some(chunk) = match tokio::time::timeout(IDLE_TIMEOUT, output.next()).await {
|
||||
Ok(chunk) => chunk,
|
||||
Err(_) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bridged port {} idle for {}s — closing the tunnel",
|
||||
port,
|
||||
IDLE_TIMEOUT.as_secs()
|
||||
);
|
||||
None
|
||||
}
|
||||
} {
|
||||
match chunk {
|
||||
// Only stdout is payload. The exec is created with tty = false
|
||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||
@@ -268,3 +611,218 @@ pub async fn tunnel_connection_with_prelude(
|
||||
// Explicit: stop reading from the host now that the container side is gone.
|
||||
drop(upstream);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn head(lines: &[&str]) -> String {
|
||||
format!("{}\r\n\r\n", lines.join("\r\n"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cli_callback_with_no_fetch_metadata_is_forwarded() {
|
||||
// The overwhelmingly common case, and the reason the gate fails open:
|
||||
// `curl`, a CLI's own probe, and anything not a browser send none of
|
||||
// these headers, and none of them can be judged from the wire.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /callback?code=abc HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"User-Agent: curl/8.5.0",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oauth_redirect_is_forwarded_even_though_it_is_cross_site() {
|
||||
// This is the feature. The provider bounces the browser to
|
||||
// `http://localhost:<port>/callback`, which is cross-site and a
|
||||
// navigation. Refusing it would refuse every login the bridge exists
|
||||
// for.
|
||||
for site in ["cross-site", "same-site"] {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /callback?code=abc&state=xyz HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
&format!("Sec-Fetch-Site: {}", site),
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
"Sec-Fetch-Dest: document",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow, "site={}", site);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_post_callback_is_forwarded() {
|
||||
// `response_mode=form_post` providers POST the callback as a
|
||||
// navigation. Still a navigation, still allowed.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"POST /callback HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Origin: https://login.microsoftonline.com",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cross_site_subresource_from_a_web_page_is_refused() {
|
||||
// The case the gate exists for: a page the user happens to have open
|
||||
// scanning loopback and poking whatever answers.
|
||||
for mode in ["cors", "no-cors", "same-origin", "websocket"] {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /admin HTTP/1.1",
|
||||
"Host: 127.0.0.1:41733",
|
||||
"Origin: https://evil.example",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
&format!("Sec-Fetch-Mode: {}", mode),
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::RefuseCrossSite, "mode={}", mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_containers_own_same_origin_requests_are_forwarded() {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /style.css HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
"Sec-Fetch-Mode: no-cors",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
// `none` is a user-initiated load — typed URL, bookmark.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET / HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Sec-Fetch-Site: none",
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_fetch_metadata_is_refused_rather_than_resolved() {
|
||||
// Last-occurrence-wins is what turns any header-smuggling primitive
|
||||
// into a bypass, and no real client sends two.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /x HTTP/1.1",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
"Sec-Fetch-Mode: cors",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::RefuseCrossSite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_head_is_ever_judged() {
|
||||
// A cross-site `text/plain` POST is not preflighted, so its *body* is
|
||||
// fully attacker-chosen. `tunnel_connection` slices at the blank line
|
||||
// before calling in; this pins that the slice is what gets judged.
|
||||
let raw = "POST /x HTTP/1.1\r\n\
|
||||
Sec-Fetch-Site: cross-site\r\n\
|
||||
Sec-Fetch-Mode: cors\r\n\
|
||||
Content-Type: text/plain\r\n\r\n\
|
||||
Sec-Fetch-Site: same-origin\r\n";
|
||||
let head_len = find_head_end(raw.as_bytes()).expect("head terminator");
|
||||
let head = &raw[..head_len];
|
||||
assert!(!head.contains("same-origin"), "the forged line must be past the slice");
|
||||
assert_eq!(web_request_verdict(head), Verdict::RefuseCrossSite);
|
||||
|
||||
// And if the slice were ever got wrong, the duplicate rule is the
|
||||
// backstop: a forged `Sec-Fetch-*` line is by construction a second
|
||||
// copy of one the browser already sent, which is refused outright
|
||||
// rather than resolved in the forgery's favour.
|
||||
assert_eq!(web_request_verdict(raw), Verdict::RefuseCrossSite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_http_first_line_is_never_treated_as_a_request() {
|
||||
// Bridged ports are not all HTTP. Anything whose first line is not a
|
||||
// request line is forwarded verbatim rather than parsed.
|
||||
assert!(!is_http_request_line(b"\x16\x03\x01\x02\x00\x01"));
|
||||
assert!(!is_http_request_line(b"*1\r"));
|
||||
assert!(!is_http_request_line(b"SSH-2.0-OpenSSH_9.6"));
|
||||
assert!(!is_http_request_line(b"GET /x HTTP/2.0"));
|
||||
assert!(!is_http_request_line(b"get /x HTTP/1.1"));
|
||||
assert!(is_http_request_line(b"GET /x HTTP/1.1\r"));
|
||||
assert!(is_http_request_line(b"POST /callback?code=a%20b HTTP/1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_end_is_found_for_both_terminators() {
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nBODY"), Some(18));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\nBODY"), Some(16));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_that_says_nothing_never_costs_a_container_exec() {
|
||||
// Every accepted connection would otherwise spawn a `docker exec`
|
||||
// immediately, so silence was free for the caller and expensive here.
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let _client = TcpStream::connect(addr).await.expect("connect");
|
||||
let started = tokio::time::Instant::now();
|
||||
let result = accept.await.expect("join");
|
||||
|
||||
assert!(result.is_err(), "silence should not be forwarded");
|
||||
assert!(
|
||||
started.elapsed() >= FIRST_BYTE_TIMEOUT,
|
||||
"should have waited out the first-byte grace period"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_http_client_is_classified_from_its_first_line_alone() {
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||
client.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.expect("write");
|
||||
|
||||
let result = accept.await.expect("join").expect("classified");
|
||||
// Verbatim, and without waiting for a head terminator that will never
|
||||
// come — the whole buffer is replayed into the tunnel.
|
||||
assert!(matches!(result, LeadingBytes::Opaque(_)));
|
||||
assert_eq!(result.into_buffer(), b"SSH-2.0-OpenSSH_9.6\r\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_http_head_is_read_whole_and_replayed_whole() {
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let raw = b"POST /callback HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ncode";
|
||||
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||
client.write_all(raw).await.expect("write");
|
||||
|
||||
let result = accept.await.expect("join").expect("classified");
|
||||
match &result {
|
||||
LeadingBytes::HttpRequest { buffer, head_len } => {
|
||||
assert_eq!(&buffer[*head_len..], b"code", "body must survive the peek");
|
||||
assert!(!buffer[..*head_len].ends_with(b"code"));
|
||||
}
|
||||
LeadingBytes::Opaque(_) => panic!("should have been recognised as HTTP"),
|
||||
}
|
||||
assert_eq!(result.into_buffer(), raw.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,21 +304,7 @@ impl BrowserViewManager {
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
let container_port = pick_viewer_port(&container_id).await?;
|
||||
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(&container_id).await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
return Err(explain_start_failure(&e, &log));
|
||||
}
|
||||
};
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
@@ -601,12 +587,91 @@ async fn read_viewer_log(container_id: &str) -> String {
|
||||
// Readiness, ports, URLs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
/// How many free ports a start will try before giving up.
|
||||
///
|
||||
/// More than one because port choice is a check-then-bind: the free list comes
|
||||
/// from a snapshot of the container's `/proc/net/tcp`, and anything in the
|
||||
/// container may bind the port we picked before the dashboard gets to it. One
|
||||
/// retry per lost race is the recovery; the cap is what stops a container that
|
||||
/// binds every candidate from holding a start open for
|
||||
/// `MAX_PORT_ATTEMPTS × READY_TIMEOUT`.
|
||||
const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Get a viewer listening inside the container and return the port it is on
|
||||
/// plus the path the pane should load.
|
||||
///
|
||||
/// ## The check/bind race
|
||||
///
|
||||
/// [`pick_viewer_port`] reads a *snapshot* of container listeners; the dashboard
|
||||
/// binds some milliseconds later. Nothing here can make that atomic — the bind
|
||||
/// happens in another process, in another namespace, and `playwright-cli show`
|
||||
/// reports the port it actually took only on a first-ever start (see
|
||||
/// [`wait_until_ready`]). What is possible is to stop treating the first
|
||||
/// candidate as the only one: if the port we picked does not come up, walk to
|
||||
/// the next free candidate rather than failing the whole start.
|
||||
///
|
||||
/// Residual, stated rather than glossed: a container-side process that binds the
|
||||
/// candidate port *and answers HTTP* is indistinguishable from the dashboard at
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
// the exhausted range is the symptom, the last start failure is
|
||||
// the thing the user can act on.
|
||||
return Err(match last {
|
||||
Some(prev) => format!("{} ({})", e, prev),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last.unwrap_or_else(|| "The Playwright viewer did not start.".to_string()))
|
||||
}
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
"cat".to_string(),
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
// can rebind to a shim. A shimmed listener list is a shimmed answer
|
||||
// to "which port is free" — i.e. the container choosing which port
|
||||
// the viewer, and therefore the host-side proxy, ends up on.
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
@@ -616,7 +681,7 @@ async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
.find(|p| !taken.contains_key(p))
|
||||
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||
|
||||
@@ -1188,16 +1188,53 @@ pub async fn has_claude_token() -> Result<bool, String> {
|
||||
Ok(secure::has_claude_oauth_token())
|
||||
}
|
||||
|
||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is always
|
||||
/// gone by the time this is returned — the rest is about copies of the token
|
||||
/// that live outside it.
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
/// The tail of every refusal [`crate::project_lock::try_acquire`] produces.
|
||||
///
|
||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] folds two very
|
||||
/// different things into one `failed` list: an image that genuinely could not
|
||||
/// be rewritten, and one that was never *attempted* because another operation
|
||||
/// held the project. Only the second is retryable, and only the second should
|
||||
/// be described to the user as "come back in a minute" rather than "reset this
|
||||
/// project". Splitting them needs a discriminator, and the refusal string is
|
||||
/// the only one that crosses the module boundary — `try_acquire` returns
|
||||
/// `Result<ProjectGuard, String>`, and `container.rs` pushes that `String`
|
||||
/// through unchanged.
|
||||
///
|
||||
/// Matching on prose is normally a mistake, so this is pinned by
|
||||
/// [`tests::a_real_lock_refusal_is_recognised_as_retryable`], which builds a
|
||||
/// refusal by actually taking a guard rather than by copying the wording. If
|
||||
/// `project_lock` ever rephrases, that test fails instead of this silently
|
||||
/// misclassifying a credential that was left in place.
|
||||
const PROJECT_BUSY_MARKER: &str = "Wait for it to finish before ";
|
||||
|
||||
/// Whether a scrub failure means "somebody else has this project right now",
|
||||
/// which is transient, rather than "this image cannot be rewritten", which is
|
||||
/// not. Nothing bollard returns contains [`PROJECT_BUSY_MARKER`].
|
||||
fn is_project_busy_refusal(reason: &str) -> bool {
|
||||
reason.contains(PROJECT_BUSY_MARKER)
|
||||
}
|
||||
|
||||
/// What a cleanup managed to reach. Every field is about copies of the token
|
||||
/// that live *outside* the keychain — snapshot images — so the same shape
|
||||
/// serves [`clear_claude_token`], where the keychain entry is already gone by
|
||||
/// the time this is returned, and [`sweep_claude_token_snapshots`], where the
|
||||
/// keychain was never touched.
|
||||
///
|
||||
/// Three lists rather than one, because "we rewrote it", "we could not rewrite
|
||||
/// it" and "we did not try" are three different things to tell somebody who
|
||||
/// just revoked a credential, and only the last one is fixed by waiting.
|
||||
#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct ClearTokenOutcome {
|
||||
/// Snapshot images that were holding the token and have been rewritten.
|
||||
pub snapshots_scrubbed: Vec<String>,
|
||||
/// Images still holding it, with the reason each could not be rewritten.
|
||||
/// Non-empty means the revocation is **incomplete** and the UI must say so.
|
||||
pub snapshots_failed: Vec<String>,
|
||||
/// Images still holding it that were **not attempted**, because another
|
||||
/// operation held the project (a start, a compaction, a migration). Also an
|
||||
/// incomplete revocation — but a retryable one, and the UI must not offer
|
||||
/// "Reset the project" as the remedy for it.
|
||||
pub snapshots_skipped: Vec<String>,
|
||||
/// Rewritten, but the pre-rewrite image object could not be deleted because
|
||||
/// a container is still running off it. Worth mentioning, not worth
|
||||
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
||||
@@ -1207,7 +1244,130 @@ pub struct ClearTokenOutcome {
|
||||
pub docker_unavailable: Option<String>,
|
||||
}
|
||||
|
||||
/// Forget the shared Claude token.
|
||||
impl ClearTokenOutcome {
|
||||
/// Whether a copy of the credential is known — or suspected — to still be
|
||||
/// reachable, so the caller should offer to run the sweep again.
|
||||
///
|
||||
/// `snapshots_superseded` is deliberately not counted: that image is
|
||||
/// untagged, nothing new is built from it, and it goes away on the next
|
||||
/// restart. Re-running would report it forever and train the user to
|
||||
/// ignore the warning.
|
||||
pub fn needs_another_pass(&self) -> bool {
|
||||
!self.snapshots_failed.is_empty()
|
||||
|| !self.snapshots_skipped.is_empty()
|
||||
|| self.docker_unavailable.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a scrub report into the IPC shape, splitting the busy projects out of
|
||||
/// the failures. Separate from the command so it can be tested without Docker.
|
||||
fn summarise_scrub(report: crate::docker::container::SnapshotScrubReport) -> ClearTokenOutcome {
|
||||
let mut outcome = ClearTokenOutcome {
|
||||
snapshots_scrubbed: report.scrubbed,
|
||||
snapshots_superseded: report.superseded_retained,
|
||||
docker_unavailable: report.unavailable,
|
||||
..Default::default()
|
||||
};
|
||||
for (image, reason) in report.failed {
|
||||
let line = format!("{}: {}", image, reason);
|
||||
if is_project_busy_refusal(&reason) {
|
||||
outcome.snapshots_skipped.push(line);
|
||||
} else {
|
||||
outcome.snapshots_failed.push(line);
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Which halves of a cleanup to run.
|
||||
///
|
||||
/// The distinction has to exist **on the wire**, not in a toast string. The UI
|
||||
/// offers a "Retry snapshot cleanup" button after an incomplete revocation, and
|
||||
/// while [`clear_claude_token`] was the only command behind it that button was
|
||||
/// a *second revoke* wearing a retry's label: it deleted the keychain entry
|
||||
/// unconditionally, with no confirmation, in a panel that survived the user
|
||||
/// re-authenticating from the button directly above it. Pressing it then threw
|
||||
/// away the token they had just acquired and said only that some images had
|
||||
/// been checked.
|
||||
///
|
||||
/// [`Cleanup::ImagesOnly`] is the honest primitive the retry actually wanted:
|
||||
/// the images are the durable record of what is left to do, so re-deriving the
|
||||
/// work from Docker needs no keychain entry and must not consume one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Cleanup {
|
||||
/// Delete the keychain entry, then rewrite the images. What "Revoke" does,
|
||||
/// behind its confirmation modal.
|
||||
KeychainThenImages,
|
||||
/// Rewrite the images and leave the keychain entirely alone. What "Retry
|
||||
/// snapshot cleanup" and "Check snapshot images" do.
|
||||
ImagesOnly,
|
||||
}
|
||||
|
||||
/// The body of both cleanup commands, with its two halves injected so the
|
||||
/// *order* — and the fact that [`Cleanup::ImagesOnly`] never reaches the
|
||||
/// keychain at all — can be tested without a keychain or a Docker daemon.
|
||||
async fn run_cleanup<K, S, F>(
|
||||
what: Cleanup,
|
||||
delete_keychain: K,
|
||||
sweep: S,
|
||||
) -> Result<ClearTokenOutcome, String>
|
||||
where
|
||||
K: FnOnce() -> Result<(), String>,
|
||||
S: FnOnce() -> F,
|
||||
F: std::future::Future<Output = crate::docker::container::SnapshotScrubReport>,
|
||||
{
|
||||
let revoking = what == Cleanup::KeychainThenImages;
|
||||
|
||||
if revoking {
|
||||
// First, and before anything slow — see "Why the keychain goes first".
|
||||
// Nothing has been touched if this fails, so the error is the whole
|
||||
// answer: the caller still has its Revoke button, and the standalone
|
||||
// sweep is available for the images regardless.
|
||||
if let Err(e) = delete_keychain() {
|
||||
log::error!(
|
||||
"Could not delete the shared Claude token from the keychain; no snapshot image \
|
||||
was touched: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("Cleared the shared Claude authentication token from the keychain");
|
||||
}
|
||||
|
||||
let report = sweep().await;
|
||||
let swept_clean = !report.left_something_behind();
|
||||
let outcome = summarise_scrub(report);
|
||||
|
||||
let lead = if revoking {
|
||||
"Revoked the shared Claude token but"
|
||||
} else {
|
||||
"Swept the snapshot images but"
|
||||
};
|
||||
for image in &outcome.snapshots_failed {
|
||||
log::warn!("{} could not clear it from {}", lead, image);
|
||||
}
|
||||
for image in &outcome.snapshots_skipped {
|
||||
log::warn!(
|
||||
"{} left it in {} — the project was busy; running the snapshot sweep again will retry",
|
||||
lead,
|
||||
image
|
||||
);
|
||||
}
|
||||
if let Some(ref reason) = outcome.docker_unavailable {
|
||||
log::warn!("{} checked no snapshot image at all: {}", lead, reason);
|
||||
}
|
||||
if swept_clean && !outcome.needs_another_pass() {
|
||||
log::info!(
|
||||
"No snapshot image is still holding the shared Claude token ({} rewritten)",
|
||||
outcome.snapshots_scrubbed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Forget the shared Claude token, and remove the copies of it that outlive the
|
||||
/// keychain entry.
|
||||
///
|
||||
/// Deleting the keychain entry is the easy half. The token also exists in two
|
||||
/// other places, and a "Revoke" button that leaves either of them behind is
|
||||
@@ -1223,34 +1383,81 @@ pub struct ClearTokenOutcome {
|
||||
/// as long as the image exists. New commits no longer bake it in (see
|
||||
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
||||
/// committed by earlier builds have to be rewritten, which is what
|
||||
/// [`scrub_secrets_from_snapshots`] does here.
|
||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] does here.
|
||||
///
|
||||
/// The keychain deletion is never rolled back if the scrub fails; a partially
|
||||
/// completed revocation is still better than none, and the outcome is reported
|
||||
/// so the UI can be explicit about what is left.
|
||||
/// ## Why the keychain goes first
|
||||
///
|
||||
/// The sweep is not quick. It lists every `triple-c-snapshot-*` image and then
|
||||
/// inspects, creates, commits and removes *per image*, over bollard's Docker
|
||||
/// socket with its 120-second-per-request default. Deferring the keychain
|
||||
/// delete behind all of that leaves the credential live for the whole window
|
||||
/// while the UI says "Revoking…", and two separate things go wrong in it:
|
||||
///
|
||||
/// * A quit, a crash or a kill mid-sweep and the entry was never deleted at
|
||||
/// all. The token the user believes they revoked is still in the keychain,
|
||||
/// still ~1-year valid, and still injected into every container start.
|
||||
/// * [`has_claude_token`] stays true throughout, and
|
||||
/// [`crate::docker::container::create_container`] reads the keychain at
|
||||
/// container-**create** time rather than at app start. The per-project
|
||||
/// [`crate::project_lock::ProjectOp::SecretScrub`] guard is released as soon
|
||||
/// as that one project's image has been rewritten — so a project scrubbed
|
||||
/// early in the sweep can be started again later in the *same* sweep and be
|
||||
/// handed a fresh copy of the credential in its env. The images end up clean
|
||||
/// and the running fleet does not.
|
||||
///
|
||||
/// An earlier version ran the sweep first, on the argument that a crash
|
||||
/// mid-sweep would otherwise leave the token in an image with the keychain
|
||||
/// entry — and therefore the Revoke button — already gone. That argument was
|
||||
/// about *recoverability*, and [`sweep_claude_token_snapshots`] answers it
|
||||
/// directly: the images are the durable record, so the retry needs no keychain
|
||||
/// entry to exist and no persisted to-do list. The comment that ordering
|
||||
/// carried ("no window in which a scrubbed image is re-poisoned") was true of
|
||||
/// images and silent about containers, which is where the leak was, and silent
|
||||
/// about the minutes the token stayed live.
|
||||
///
|
||||
/// The keychain deletion is never rolled back if the scrub then fails; a
|
||||
/// partially completed revocation is still better than none, and the outcome is
|
||||
/// reported so the UI can be explicit about what is left.
|
||||
#[tauri::command]
|
||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||
secure::delete_claude_oauth_token()?;
|
||||
log::info!("Cleared the shared Claude authentication token");
|
||||
run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
secure::delete_claude_oauth_token,
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
||||
if report.left_something_behind() {
|
||||
log::warn!(
|
||||
"Revoked the shared Claude token but {} snapshot image(s) may still contain it",
|
||||
report.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ClearTokenOutcome {
|
||||
snapshots_scrubbed: report.scrubbed,
|
||||
snapshots_failed: report
|
||||
.failed
|
||||
.into_iter()
|
||||
.map(|(image, reason)| format!("{}: {}", image, reason))
|
||||
.collect(),
|
||||
snapshots_superseded: report.superseded_retained,
|
||||
docker_unavailable: report.unavailable,
|
||||
})
|
||||
/// Rewrite every snapshot image that still carries a credential, **without
|
||||
/// touching the keychain**.
|
||||
///
|
||||
/// This is the retry, and it is its own command because the retry is its own
|
||||
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||
///
|
||||
/// Safe to call at any time and in any state:
|
||||
///
|
||||
/// * with a token stored — a snapshot committed by an older build carries the
|
||||
/// *current* token, and clearing it out of the image does not stop the
|
||||
/// keychain entry being injected on the next container start;
|
||||
/// * with nothing stored — images committed by earlier builds still carry
|
||||
/// whatever token was live when they were committed, which is exactly the
|
||||
/// case the old sweep-first ordering could strand;
|
||||
/// * repeatedly — the work is re-derived from Docker each time, so an image
|
||||
/// whose project was busy on the last pass is simply picked up on this one.
|
||||
#[tauri::command]
|
||||
pub async fn sweep_claude_token_snapshots() -> Result<ClearTokenOutcome, String> {
|
||||
run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
// Never called; the `ImagesOnly` branch is the entire point of this
|
||||
// command, and a change that made it reachable must fail loudly rather
|
||||
// than delete a credential quietly.
|
||||
|| -> Result<(), String> { unreachable!("an images-only sweep must never touch the keychain") },
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1761,5 +1968,265 @@ mod tests {
|
||||
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
||||
assert_eq!(parse_setup_token(&seen), Some(tok));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revocation: what the sweep leaves behind, and how it is described ──
|
||||
|
||||
use crate::docker::container::SnapshotScrubReport;
|
||||
use crate::project_lock::{try_acquire, ProjectOp};
|
||||
|
||||
/// The classifier is a substring match on a message another module owns,
|
||||
/// which is only safe if something notices when that module rephrases. So
|
||||
/// build the refusal the way production does — by actually losing the
|
||||
/// race — rather than by pasting the wording in here.
|
||||
#[test]
|
||||
fn a_real_lock_refusal_is_recognised_as_retryable() {
|
||||
let project = "auth-token-test-busy-project";
|
||||
let _held = try_acquire(project, ProjectOp::Compaction).expect("first claim");
|
||||
let refusal = try_acquire(project, ProjectOp::SecretScrub)
|
||||
.expect_err("a second claim on the same project must be refused");
|
||||
|
||||
assert!(
|
||||
is_project_busy_refusal(&refusal),
|
||||
"project_lock's refusal is no longer recognised as retryable: {:?}",
|
||||
refusal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_failure_is_not_mistaken_for_a_busy_project() {
|
||||
for reason in [
|
||||
"could not inspect: error trying to connect: No such file or directory",
|
||||
"could not create a scratch container: conflict: name already in use",
|
||||
"an untagged snapshot image holds a credential and cannot be rewritten",
|
||||
] {
|
||||
assert!(
|
||||
!is_project_busy_refusal(reason),
|
||||
"{:?} was misclassified as a transient lock refusal",
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarise_scrub_separates_a_busy_project_from_a_broken_image() {
|
||||
let project = "auth-token-test-summarise-busy";
|
||||
let _held = try_acquire(project, ProjectOp::Recreate).expect("first claim");
|
||||
let refusal = try_acquire(project, ProjectOp::SecretScrub).expect_err("refused");
|
||||
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
failed: vec![
|
||||
("triple-c-snapshot-b:latest".into(), refusal),
|
||||
(
|
||||
"triple-c-snapshot-c:latest".into(),
|
||||
"could not create a scratch container: no such image".into(),
|
||||
),
|
||||
],
|
||||
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||
unavailable: None,
|
||||
});
|
||||
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_skipped[0].starts_with("triple-c-snapshot-b:latest: "));
|
||||
assert_eq!(outcome.snapshots_failed.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed[0].starts_with("triple-c-snapshot-c:latest: "));
|
||||
// The whole point: a skipped image is never folded into the scrubbed
|
||||
// list, which is what "success" is rendered from.
|
||||
assert!(!outcome.snapshots_scrubbed.iter().any(|s| s.contains("snapshot-b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_sweep_needs_no_second_pass() {
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!outcome.needs_another_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retained_superseded_image_alone_does_not_ask_for_a_second_pass() {
|
||||
// The tag is clean; what is left is untagged and dies with the running
|
||||
// container. Asking the user to sweep again would never stop.
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!outcome.needs_another_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_still_holding_the_credential_asks_for_a_second_pass() {
|
||||
let skipped = summarise_scrub(SnapshotScrubReport {
|
||||
failed: vec![(
|
||||
"triple-c-snapshot-b:latest".into(),
|
||||
format!("This project is being reset. {}resetting it.", PROJECT_BUSY_MARKER),
|
||||
)],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(skipped.needs_another_pass());
|
||||
assert_eq!(skipped.snapshots_skipped.len(), 1);
|
||||
|
||||
let failed = summarise_scrub(SnapshotScrubReport {
|
||||
failed: vec![("triple-c-snapshot-c:latest".into(), "could not inspect: boom".into())],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(failed.needs_another_pass());
|
||||
|
||||
let blind = summarise_scrub(SnapshotScrubReport {
|
||||
unavailable: Some("Docker is not running".into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(blind.needs_another_pass());
|
||||
assert!(blind.snapshots_scrubbed.is_empty());
|
||||
}
|
||||
|
||||
/// The IPC contract the frontend reads. A field renamed on this side and
|
||||
/// not on that one is a silent "nothing was skipped".
|
||||
#[test]
|
||||
fn the_outcome_serialises_under_the_names_the_frontend_reads() {
|
||||
let json = serde_json::to_value(ClearTokenOutcome::default()).expect("serialise");
|
||||
let object = json.as_object().expect("an object");
|
||||
for key in [
|
||||
"snapshots_scrubbed",
|
||||
"snapshots_failed",
|
||||
"snapshots_skipped",
|
||||
"snapshots_superseded",
|
||||
"docker_unavailable",
|
||||
] {
|
||||
assert!(object.contains_key(key), "missing {} in {:?}", key, object);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The order of a revocation, and what a retry may touch ─────────────
|
||||
//
|
||||
// `run_cleanup` takes both halves as arguments precisely so this can be
|
||||
// asserted with no keychain and no Docker daemon: the recorded order *is*
|
||||
// the subject. Sweep-first put a live ~1-year credential behind a
|
||||
// per-image inspect/create/commit/rmi loop — minutes, at bollard's
|
||||
// 120s-per-request default — during which `has_claude_token` stayed true
|
||||
// and `create_container` kept handing the token to anything started.
|
||||
|
||||
/// Records which half ran, in order.
|
||||
type Trace = std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>;
|
||||
|
||||
fn trace() -> Trace {
|
||||
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn scrubbed_one() -> SnapshotScrubReport {
|
||||
SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_keychain_entry_is_gone_before_the_first_image_is_touched() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a cleanup whose halves both succeed is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["keychain", "sweep"],
|
||||
"the token stayed in the keychain — and therefore in every container created — \
|
||||
for the whole length of the image sweep"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_keychain_failure_leaves_the_images_untouched_and_is_reported() {
|
||||
let t = trace();
|
||||
let ts = t.clone();
|
||||
|
||||
let err = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
|| Err("the keychain is locked".to_string()),
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
SnapshotScrubReport::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a keychain that refused the delete must be reported, not swallowed");
|
||||
|
||||
assert_eq!(err, "the keychain is locked");
|
||||
assert!(
|
||||
t.lock().unwrap().is_empty(),
|
||||
"images were rewritten for a revocation that never happened; the report is then \
|
||||
discarded with the error and the user is told nothing they can act on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug the images-only primitive exists to close: the "Retry snapshot
|
||||
/// cleanup" button used to run `clear_claude_token`, so pressing it after
|
||||
/// re-authenticating deleted the brand-new token with no confirmation.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_never_reaches_the_keychain() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a sweep-only cleanup is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["sweep"],
|
||||
"the retry deleted a credential nobody confirmed deleting"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
/// …and it still has to report what it could not finish, because "run it
|
||||
/// again once that project is idle" is the whole affordance.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_still_reports_what_it_could_not_finish() {
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
|| -> Result<(), String> { unreachable!("images only") },
|
||||
|| async {
|
||||
SnapshotScrubReport {
|
||||
failed: vec![(
|
||||
"triple-c-snapshot-b:latest".into(),
|
||||
format!("This project is being started. {}removing a credential from its snapshot.", PROJECT_BUSY_MARKER),
|
||||
)],
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("an image left for the next pass is not a command failure");
|
||||
|
||||
assert!(outcome.needs_another_pass());
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
||||
let containers = docker::list_sibling_containers().await?;
|
||||
let result: Vec<serde_json::Value> = containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"names": c.names,
|
||||
"image": c.image,
|
||||
"state": c.state,
|
||||
"status": c.status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1200,9 +1200,14 @@ pub async fn add_scheduled_task(
|
||||
/// * **In that order**, so a rejected `add` leaves the original untouched
|
||||
/// rather than deleting a prompt the user cannot get back. The cost is a
|
||||
/// sub-second window in which both tasks are in the crontab.
|
||||
/// * The task therefore gets a **new id**. Its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
||||
/// UI warns about this before saving.
|
||||
/// * The task therefore gets a **new id**, and its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||
/// scheduler reaps a task's logs when the task stops existing, because
|
||||
/// nothing can name that id again afterwards. The UI warns before saving.
|
||||
/// (A project still running an older base image carries the older
|
||||
/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
|
||||
/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
|
||||
/// copy is deliberately written for the case that loses data.)
|
||||
/// * `enabled` is carried over explicitly, because `add` always creates an
|
||||
/// enabled task and silently re-enabling a task the user had switched off
|
||||
/// would schedule a run they did not ask for.
|
||||
|
||||
@@ -227,58 +227,62 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
|
||||
// Migrate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Project ids with a migration running **in this process right now**.
|
||||
///
|
||||
/// Two things need it. `reconcile_project_statuses` is callable from the
|
||||
/// frontend at any time, not only at startup, and a live migration looks
|
||||
/// exactly like a crashed one from the outside (state file says `in-progress`,
|
||||
/// container carries the label) — without this guard a reconcile mid-run would
|
||||
/// rewrite the phase to `interrupted` underneath a migration that is fine.
|
||||
/// It also makes a second concurrent `migrate_project_to_base` for the same
|
||||
/// project impossible.
|
||||
static ACTIVE_MIGRATIONS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashSet<String>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
fn active_migrations() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
ACTIVE_MIGRATIONS.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Whether a migration for this project is running **in this process right
|
||||
/// now**. Every command that stops, removes or recreates the project's
|
||||
/// container has to consult it: the window between `remove_container` and the
|
||||
/// create that follows looks exactly like "no container", and an ordinary
|
||||
/// Start landing in it creates a second container under the same name.
|
||||
///
|
||||
/// **This is now a view onto [`crate::project_lock`], not a set of its own.**
|
||||
/// It used to be the app's only mutual-exclusion primitive, and it was one-way:
|
||||
/// a migration claimed a project, everything else merely polled this once at
|
||||
/// entry and never claimed anything. Two non-migration writers of
|
||||
/// `triple-c-snapshot-{id}:latest` — a compaction and a recreate — could not
|
||||
/// see each other at all. Folding the set into the shared registry means there
|
||||
/// is exactly one answer to "is something happening to this project", and this
|
||||
/// function is the specialisation of it that reconcile still needs: a *live*
|
||||
/// migration is indistinguishable from a crashed one from the outside, and only
|
||||
/// this process knows which it is looking at.
|
||||
///
|
||||
/// No production caller on this branch: the Disk panel's survey was the last
|
||||
/// one, and it went to `hold/disk-and-dragout`. Kept — and still exercised by
|
||||
/// `a_live_migration_is_distinguishable_from_a_crashed_one` — because it is the
|
||||
/// one named answer to that question and re-inventing it is how the two
|
||||
/// disagreeing answers happened the first time.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||
active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains(project_id)
|
||||
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
|
||||
}
|
||||
|
||||
/// RAII marker: removes the project from [`ACTIVE_MIGRATIONS`] however the
|
||||
/// migration ends, including an early `?`.
|
||||
struct ActiveGuard(String);
|
||||
/// RAII marker: releases the project's [`crate::project_lock`] claim however
|
||||
/// the migration ends, including an early `?`.
|
||||
///
|
||||
/// Kept as a named type rather than using [`crate::project_lock::ProjectGuard`]
|
||||
/// directly so the migration path keeps reading as "take the migration guard",
|
||||
/// and so the one place that decides what a migration's claim *is* stays here.
|
||||
struct ActiveGuard(#[allow(dead_code)] crate::project_lock::ProjectGuard);
|
||||
|
||||
impl ActiveGuard {
|
||||
/// `None` when a migration is already running for this project.
|
||||
fn acquire(project_id: &str) -> Option<Self> {
|
||||
let mut set = active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if !set.insert(project_id.to_string()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self(project_id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ActiveGuard {
|
||||
fn drop(&mut self) {
|
||||
active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.0);
|
||||
/// `Err` with the registry's own refusal when a migration — **or anything
|
||||
/// else** — already holds this project.
|
||||
///
|
||||
/// The error string is the point. This returned `Option`, and all three
|
||||
/// callers replaced the discarded reason with a sentence about a migration
|
||||
/// — so a user blocked by a *compaction*, a reset or a cache clear was told
|
||||
/// to wait for a base update that was not running, with nothing in the UI
|
||||
/// that could ever name what actually held the project.
|
||||
/// `project_lock::try_acquire` already composes "what holds it" with "what
|
||||
/// you were trying to do"; there is nothing to add to it here.
|
||||
///
|
||||
/// The tail it composes is `ProjectOp::Migration`'s — "…before starting a
|
||||
/// base update" — for confirm and rollback as well as for the migration
|
||||
/// itself. That is the class all three belong to, and splitting it would
|
||||
/// mean a `ProjectOp` variant per command: the wrong place to encode a
|
||||
/// verb, for a phrase that is at worst imprecise where the old one was
|
||||
/// simply wrong.
|
||||
fn acquire(project_id: &str) -> Result<Self, String> {
|
||||
crate::project_lock::try_acquire(project_id, crate::project_lock::ProjectOp::Migration)
|
||||
.map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,10 +298,9 @@ pub async fn migrate_project_to_base(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MigrationReport, String> {
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Ok(MigrationReport::failed_preflight(
|
||||
"A migration is already running for this project.",
|
||||
));
|
||||
let _guard = match ActiveGuard::acquire(&project_id) {
|
||||
Ok(guard) => guard,
|
||||
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
|
||||
};
|
||||
|
||||
let existing = migration_store::load(&project_id)?;
|
||||
@@ -460,6 +463,33 @@ async fn fresh_migration(
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub *before* the stop, not inside the commit below. `scrub_writable_layer`
|
||||
// is a `docker exec`, which only works on a running container — and the
|
||||
// pre-swap commit is the single largest snapshot Triple-C ever takes, so
|
||||
// letting this one path commit unscrubbed is what the scrub exists to
|
||||
// prevent. Failure is swallowed inside; it must never block a migration.
|
||||
//
|
||||
// The outcome is logged rather than discarded: this is the one scrub whose
|
||||
// silence would be expensive, because the layer it declined to clean is
|
||||
// about to be committed into a snapshot that outlives the migration.
|
||||
//
|
||||
// H2: **bind it first.** `ScrubOutcome` is `#[must_use]`, and the way that
|
||||
// was satisfied here was by folding the awaited call into `log::info!`'s
|
||||
// argument list. `log::info!` expands to
|
||||
// `if Info <= max_level() { … }` — so the arguments, this data-integrity
|
||||
// step among them, live inside the level check and do not run at all when
|
||||
// the global filter is `Off`. That is reachable: `logging::init` tolerates
|
||||
// `dispatch.apply()` failing, and fern returns *before* `set_max_level` on
|
||||
// error, so a process that failed to install a logger sits at `Off` with
|
||||
// every `log::` argument list silently dead. The scrub would then never
|
||||
// run, and the unscrubbed layer would be committed into the longest-lived
|
||||
// snapshot the app takes. `commit_container_snapshot` gets this right for
|
||||
// the same reason; nothing may put an effect inside a log macro's
|
||||
// arguments. (`logging::init` now also restores the level on failure, but
|
||||
// the call site is not allowed to depend on that.)
|
||||
let scrub = docker::scrub_writable_layer(&container_id).await;
|
||||
log::info!("Pre-migration scrub of {}{}", container_id, scrub.commit_log_suffix());
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Stopping the container...");
|
||||
let _ = state
|
||||
.projects_store
|
||||
@@ -828,12 +858,7 @@ pub async fn confirm_migration(
|
||||
let _ = &state;
|
||||
// Confirming drops the only way back. Doing that underneath a running
|
||||
// migration would delete the pin it is relying on mid-flight.
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||
let Some(mstate) = migration_store::load(&project_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -863,7 +888,7 @@ pub async fn confirm_migration(
|
||||
// waiting for the project's next recreation would leave it lying around
|
||||
// indefinitely.
|
||||
tauri::async_runtime::spawn(async {
|
||||
crate::docker::sweep_orphaned_snapshots().await;
|
||||
crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -880,12 +905,7 @@ pub async fn rollback_migration(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||
|
||||
let mut project = state
|
||||
.projects_store
|
||||
@@ -957,6 +977,16 @@ pub async fn rollback_migration(
|
||||
let _ = mig::untag_image(&rollback_ref).await;
|
||||
migration_store::clear_staging(&project_id)?;
|
||||
migration_store::clear(&project_id)?;
|
||||
|
||||
// Retagging above moved `:latest` off the *migrated* snapshot, and the
|
||||
// container that was built from it was removed a few lines up — so a
|
||||
// multi-gigabyte image is sitting there untagged and unreferenced with
|
||||
// nothing else in the app that would ever look at it again. The confirm
|
||||
// path sweeps for exactly this reason; rolling back orphans just as much
|
||||
// and did not.
|
||||
tauri::async_runtime::spawn(async {
|
||||
crate::docker::sweep_orphaned_snapshots_logged("after migration rollback").await;
|
||||
});
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
@@ -988,9 +1018,203 @@ pub async fn get_migration_state(
|
||||
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
// A migration running right now is indistinguishable from a crashed one
|
||||
// from the outside; only this process knows the difference.
|
||||
if is_migrating(&project.id) {
|
||||
//
|
||||
// **Any holder, not just a migration.** This asked `is_migrating`, which is
|
||||
// `held() == Some(Migration)` — so a compaction, a reset or a destroy
|
||||
// holding the project made this fall straight through and start rewriting
|
||||
// the migration record's phase and untagging its rollback image underneath
|
||||
// whatever was running. `reconcile_project_statuses` is a command, not just
|
||||
// a startup step, so "nothing else can be running yet" is not available as
|
||||
// an argument.
|
||||
//
|
||||
// Yielding is right; yielding *forever* was not. The only caller fires once
|
||||
// per "Docker became available", so a project that happened to be held at
|
||||
// that instant was never looked at again for the rest of the session: its
|
||||
// phase stayed un-normalised, no resume or rollback was ever offered, and
|
||||
// its `:pre-migration-*` pin stayed `Claimed`. So the visit is deferred
|
||||
// rather than dropped — see [`defer_migration_reconcile`].
|
||||
if let Some(holder) = crate::project_lock::held(&project.id) {
|
||||
log::debug!(
|
||||
"Deferring migration reconcile for '{}' ({}): {}",
|
||||
project.name,
|
||||
project.id,
|
||||
holder.describe()
|
||||
);
|
||||
defer_migration_reconcile(project, app_handle);
|
||||
return;
|
||||
}
|
||||
reconcile_migration_now(project, app_handle).await;
|
||||
}
|
||||
|
||||
/// How long [`defer_migration_reconcile`] waits between looks, and how many
|
||||
/// times it looks.
|
||||
///
|
||||
/// The operations it is waiting behind are minutes long — a Reset recreates a
|
||||
/// container from a base image, a compaction rebuilds a multi-gigabyte
|
||||
/// snapshot — so the interval is coarse on purpose: this is a `held()` read
|
||||
/// against an in-process map, but every wakeup is a task and the point is to
|
||||
/// catch the release, not to catch it promptly. Twenty seconds × ninety is
|
||||
/// thirty minutes, comfortably past the longest measured compaction, after
|
||||
/// which the project is left for the next `reconcile_project_statuses`.
|
||||
const RECONCILE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
const RECONCILE_RETRY_ATTEMPTS: usize = 90;
|
||||
|
||||
/// Project ids with a deferred reconcile already waiting.
|
||||
///
|
||||
/// `reconcile_project_statuses` is a command the frontend can call more than
|
||||
/// once — every "Docker became available" — and each call walks every project.
|
||||
/// Without this, a project held for a few minutes would accumulate one waiting
|
||||
/// task per call, all of which would then reconcile the same record in a row.
|
||||
fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
static RETRIES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// One project's place in [`reconcile_retries`], handed back on drop.
|
||||
///
|
||||
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
|
||||
/// claim is the case that proves the rule: the release used to be a trailing
|
||||
/// statement at the bottom of the spawned task in
|
||||
/// [`defer_migration_reconcile`], sitting after an `.await` on
|
||||
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
|
||||
/// dropped, which is what happens to every in-flight task at shutdown — skips
|
||||
/// the statement, and nothing else ever removes an id from that set. The
|
||||
/// project is then fenced off from *every* later deferral for the rest of the
|
||||
/// process: each `reconcile_project_statuses` pass finds it held, fails to
|
||||
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
|
||||
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
|
||||
/// session-long silence deferring was written to end, reintroduced one panic
|
||||
/// later and lasting until the app is restarted.
|
||||
///
|
||||
/// Dropping this hands the claim straight back, so a caller that discards the
|
||||
/// value has claimed nothing while reading as though it had; `#[must_use]`
|
||||
/// makes that a compile warning rather than a second waiter on one record.
|
||||
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
|
||||
struct ReconcileRetryClaim {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ReconcileRetryClaim {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` past poisoning, as in `project_lock`: the only thing
|
||||
// ever done while holding this mutex is a single `HashSet` insert or
|
||||
// remove, so a panic on another thread cannot have left it half
|
||||
// written — and declining to release here would strand the project
|
||||
// permanently, which is the exact failure the guard exists to stop.
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the right to be the one deferred reconcile for `project_id`.
|
||||
/// `None` means somebody else already is.
|
||||
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(project_id.to_string())
|
||||
.then(|| ReconcileRetryClaim {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
||||
///
|
||||
/// Only for projects that have a record on disk: [`migration_store::has_record`]
|
||||
/// is filesystem presence, so it costs nothing and is deliberately the *cheap*
|
||||
/// question — every project is walked on every reconcile and almost none of
|
||||
/// them have a migration in flight. A record that exists but cannot be parsed
|
||||
/// answers `true` here and is handled, conservatively, by `load` when the
|
||||
/// retry lands.
|
||||
///
|
||||
/// The wait is a poll rather than a notification because `project_lock` has no
|
||||
/// release hook and giving it one would mean a guard's `Drop` waking tasks
|
||||
/// while it still holds the map's mutex. A read of an in-process `HashMap`
|
||||
/// every twenty seconds, for as long as one operation is running on one
|
||||
/// project, is not worth a condvar.
|
||||
fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
// No record means nothing to come back for. An unreadable migrations
|
||||
// directory answers "maybe", and maybe is worth a look.
|
||||
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Some(claim) = claim_reconcile_retry(&project.id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = project.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Moved in and bound for the whole body, rather than released by a
|
||||
// statement at the bottom: everything below this line can panic or be
|
||||
// dropped mid-await, and a claim that only comes back on the happy path
|
||||
// is a claim that eventually does not come back at all. See
|
||||
// [`ReconcileRetryClaim`].
|
||||
let _claim = claim;
|
||||
let released =
|
||||
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
||||
if released {
|
||||
// Whatever was holding it may have finished the migration itself or
|
||||
// cleared the record — `reconcile_migration_now` loads the record
|
||||
// first and returns on `None`, so that is a no-op rather than a
|
||||
// special case here.
|
||||
reconcile_migration_now(&project, &app_handle).await;
|
||||
} else {
|
||||
log::warn!(
|
||||
"Gave up waiting to reconcile the migration record for '{}' ({}): it has been \
|
||||
held for {} minutes. Its phase is unchanged and its rollback pin is still \
|
||||
claimed; the next reconcile pass will try again.",
|
||||
project.name,
|
||||
project.id,
|
||||
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for `project_id` to stop being held: `attempts` looks, the first
|
||||
/// immediate and the rest `interval` apart. `true` means it was released,
|
||||
/// `false` that the budget ran out with it still held.
|
||||
///
|
||||
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
||||
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
|
||||
/// that are easy to get wrong are "gives up while still holding the claim",
|
||||
/// "never looks again", and the ordering of the look against the sleep, none of
|
||||
/// which is visible from the constants.
|
||||
async fn await_release(
|
||||
project_id: &str,
|
||||
interval: std::time::Duration,
|
||||
attempts: usize,
|
||||
) -> bool {
|
||||
for attempt in 0..attempts {
|
||||
// Look first, sleep second. Sleeping first charged every deferral a
|
||||
// full interval before anyone read the map even once, and the common
|
||||
// case is a holder that has already let go: `held()` is sampled in
|
||||
// `reconcile_migration`, a task is spawned, and by the time it is first
|
||||
// polled the Reset that was on its last step is frequently finished.
|
||||
// That bought nothing and cost twenty seconds of a startup pass waiting
|
||||
// on a lock nobody holds, in front of a check that is one `HashMap`
|
||||
// lookup.
|
||||
if crate::project_lock::held(project_id).is_none() {
|
||||
return true;
|
||||
}
|
||||
// And no sleep after the final look: nothing reads the map again
|
||||
// afterwards, so it is twenty seconds of delay in front of a `false`
|
||||
// that has already been decided. The budget is still `attempts` looks,
|
||||
// which is what the constants above are chosen against.
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// [`reconcile_migration`] with the "is anything holding this project" question
|
||||
/// already answered.
|
||||
async fn reconcile_migration_now(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
let state = match migration_store::load(&project.id) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return,
|
||||
@@ -1146,7 +1370,23 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => return,
|
||||
Ok(None) => {
|
||||
// **`Ok(None)` is not the same as "no file".** `migration_store::load`
|
||||
// now reports an *unparseable* record as absent while deliberately
|
||||
// leaving it on disk, so that `has_record` goes on protecting the
|
||||
// rollback pin it describes. Returning here on that would leave the
|
||||
// file — and therefore a permanently "claimed" pin — behind a Reset
|
||||
// that has just deleted the snapshot and both volumes the record
|
||||
// could possibly refer to.
|
||||
if !migration_store::has_record(project_id).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Project {} has a migration record that could not be read; removing it anyway \
|
||||
because a Reset supersedes it",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Could not read the migration record for {} while cleaning up: {}",
|
||||
project_id,
|
||||
@@ -1155,6 +1395,10 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
}
|
||||
let _ = migration_store::clear_staging(project_id);
|
||||
let _ = migration_store::clear(project_id);
|
||||
// The pins this project had are gone with the snapshot; their grace clocks
|
||||
// are meaningless and would otherwise sit in the migrations directory
|
||||
// forever.
|
||||
migration_store::clear_ownerless_for_project(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
@@ -1878,16 +2122,23 @@ mod tests {
|
||||
{
|
||||
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
||||
assert!(is_migrating(id));
|
||||
let refused = ActiveGuard::acquire(id)
|
||||
.err()
|
||||
.expect("a second concurrent migration must be refused");
|
||||
// The refusal has to say what is holding the project, not what the
|
||||
// caller happens to be — the three commands used to substitute
|
||||
// their own sentence for this and lost the distinction.
|
||||
assert!(
|
||||
ActiveGuard::acquire(id).is_none(),
|
||||
"a second concurrent migration must be refused"
|
||||
refused.contains("base update"),
|
||||
"the refusal must name the holder: {}",
|
||||
refused
|
||||
);
|
||||
drop(g);
|
||||
}
|
||||
assert!(!is_migrating(id), "the guard must release on drop");
|
||||
// …including when the migration bailed out through an early return.
|
||||
fn early_return(id: &str) -> Option<()> {
|
||||
let _g = ActiveGuard::acquire(id)?;
|
||||
let _g = ActiveGuard::acquire(id).ok()?;
|
||||
None
|
||||
}
|
||||
assert!(early_return(id).is_none());
|
||||
@@ -1901,4 +2152,225 @@ mod tests {
|
||||
assert!(!r.rollback_available);
|
||||
assert!(r.packages_requested.is_empty());
|
||||
}
|
||||
|
||||
/// No `await` may sit inside a `log::*!` argument list — H2, generalised.
|
||||
///
|
||||
/// `log::info!(a, b)` expands to `if Info <= max_level() { … a … b … }`, so
|
||||
/// an argument is only evaluated while the level admits the record. Folding
|
||||
/// `scrub_writable_layer(&id).await.commit_log_suffix()` into the arguments
|
||||
/// here — done to satisfy `#[must_use]` on `ScrubOutcome` — therefore made
|
||||
/// the pre-migration scrub conditional on the log level, and
|
||||
/// `logging::init` deliberately tolerates failing to install a logger,
|
||||
/// which leaves `max_level()` at `Off`. A scrub that never runs before the
|
||||
/// largest snapshot the app takes is not something a log level may decide.
|
||||
///
|
||||
/// Scanned over the source rather than asserted at one call site: the bug
|
||||
/// is a shape, and it is reintroduced by whoever next has a `#[must_use]`
|
||||
/// value they only want to log.
|
||||
#[test]
|
||||
fn nothing_awaits_inside_a_log_macros_arguments() {
|
||||
let sources: &[(&str, &str)] = &[
|
||||
("commands/migration_commands.rs", include_str!("migration_commands.rs")),
|
||||
("docker/container.rs", include_str!("../docker/container.rs")),
|
||||
("docker/migration.rs", include_str!("../docker/migration.rs")),
|
||||
("logging.rs", include_str!("../logging.rs")),
|
||||
];
|
||||
let macros = ["log::error!(", "log::warn!(", "log::info!(", "log::debug!(", "log::trace!("];
|
||||
let mut scanned = 0usize;
|
||||
for (name, src) in sources {
|
||||
for mac in macros {
|
||||
let mut from = 0usize;
|
||||
while let Some(at) = src[from..].find(mac) {
|
||||
let start = from + at + mac.len();
|
||||
// Balance the macro's own parentheses. String literals in
|
||||
// these call sites never contain an unbalanced one, and a
|
||||
// `(` inside a format string would only ever widen the
|
||||
// slice, i.e. fail safe.
|
||||
let mut depth = 1usize;
|
||||
let mut end = start;
|
||||
for (i, c) in src[start..].char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = start + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let args = &src[start..end];
|
||||
// This test's own name mentions the thing it forbids.
|
||||
assert!(
|
||||
!args.contains(".await"),
|
||||
"{}: an `.await` inside a `{}` argument list stops happening whenever the \
|
||||
log level does not admit the record:\n{}",
|
||||
name,
|
||||
mac.trim_end_matches('('),
|
||||
args
|
||||
);
|
||||
scanned += 1;
|
||||
from = end.max(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A scanner that matched nothing would pass silently forever.
|
||||
assert!(scanned > 80, "only {} log call sites were scanned", scanned);
|
||||
}
|
||||
|
||||
/// MEDIUM: a project held when the reconcile pass reached it must be
|
||||
/// revisited, not dropped for the session.
|
||||
///
|
||||
/// `reconcile_project_statuses` fires once per "Docker became available",
|
||||
/// so the old `return` meant a project that happened to be mid-Reset at
|
||||
/// that instant never had its migration phase normalised, was never offered
|
||||
/// resume or rollback, and kept its `:pre-migration-*` pin `Claimed` — for
|
||||
/// the rest of the session. On a paused clock, so the thirty-minute budget
|
||||
/// costs nothing.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_held_project_is_revisited_once_the_holder_lets_go() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
let guard = crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Reset)
|
||||
.expect("a fresh project id is not held");
|
||||
|
||||
let waiting = {
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await
|
||||
})
|
||||
};
|
||||
|
||||
// Long enough that several looks have already happened and found it
|
||||
// held, so this cannot pass by the waiter never having polled.
|
||||
tokio::time::sleep(RECONCILE_RETRY_INTERVAL * 3).await;
|
||||
assert!(!waiting.is_finished(), "the waiter returned while the project was held");
|
||||
|
||||
drop(guard);
|
||||
assert!(
|
||||
waiting.await.expect("the waiter task"),
|
||||
"the holder let go and the reconcile never came back"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the budget is finite: a project held indefinitely does not leave a
|
||||
/// task waiting on it forever, and the claim is handed back either way.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn waiting_for_a_holder_gives_up_eventually() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
let _guard =
|
||||
crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Migration)
|
||||
.expect("a fresh project id is not held");
|
||||
assert!(!await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||
// Thirty minutes: past the longest measured compaction, and the thing
|
||||
// being waited on is always a bounded, user-initiated operation.
|
||||
assert!(RECONCILE_RETRY_ATTEMPTS > 0, "deferring would be a no-op");
|
||||
let budget = RECONCILE_RETRY_INTERVAL * RECONCILE_RETRY_ATTEMPTS as u32;
|
||||
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
||||
}
|
||||
|
||||
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
|
||||
///
|
||||
/// The wait slept before its first look, so a holder that let go between
|
||||
/// `reconcile_migration` sampling `held()` and this task being polled — the
|
||||
/// *common* case, since a deferral is only taken when something was on its
|
||||
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
|
||||
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
|
||||
/// the fixed shape returns without the clock moving at all, the sleep-first
|
||||
/// shape cannot return before it has advanced one interval.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
assert!(
|
||||
crate::project_lock::held(&id).is_none(),
|
||||
"a fresh uuid is not held"
|
||||
);
|
||||
|
||||
let before = tokio::time::Instant::now();
|
||||
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||
let waited = tokio::time::Instant::now() - before;
|
||||
assert_eq!(
|
||||
waited,
|
||||
std::time::Duration::ZERO,
|
||||
"an already-released project cost {:?} before anyone looked",
|
||||
waited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_deferred_reconcile_waits_per_project() {
|
||||
// Every "Docker became available" walks every project, so without the
|
||||
// claim a project held for a few minutes accumulates one waiting task
|
||||
// per call — all of which then reconcile the same record in a row.
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_none(),
|
||||
"a second waiter was allowed in"
|
||||
);
|
||||
let other_claim =
|
||||
claim_reconcile_retry(&other).expect("the claim is not per-project");
|
||||
drop(first);
|
||||
// Bound rather than discarded: the guard releases on drop, so
|
||||
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
|
||||
// — which is what `#[must_use]` is there to catch in real callers.
|
||||
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
|
||||
drop(retaken);
|
||||
drop(other_claim);
|
||||
// And the other project's claim was never the same claim.
|
||||
drop(claim_reconcile_retry(&other).expect("released independently"));
|
||||
}
|
||||
|
||||
/// MEDIUM: the claim survives the task that holds it dying badly.
|
||||
///
|
||||
/// The release used to be a trailing statement after
|
||||
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
|
||||
/// so a panic anywhere in that call — or the future being dropped at
|
||||
/// shutdown — skipped it and left the id in the set with no task behind it.
|
||||
/// Nothing removes it afterwards, so that project could never be deferred
|
||||
/// again for the rest of the process: exactly the state deferring was added
|
||||
/// to prevent, now permanent instead of one pass long. Fails against the
|
||||
/// trailing-statement shape, which is the point.
|
||||
#[tokio::test]
|
||||
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
|
||||
// Spawned, not just called: the real claim is held across an await
|
||||
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
|
||||
// the runtime rather than unwinding the caller.
|
||||
let task = {
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
tokio::task::yield_now().await;
|
||||
panic!("reconcile_migration_now blew up on '{}'", id);
|
||||
})
|
||||
};
|
||||
assert!(task.await.is_err(), "the task was supposed to panic");
|
||||
|
||||
let after = claim_reconcile_retry(&id);
|
||||
assert!(
|
||||
after.is_some(),
|
||||
"a panicking reconcile stranded the claim — this project can never be \
|
||||
deferred again for the rest of the process"
|
||||
);
|
||||
drop(after);
|
||||
|
||||
// The other half of the same failure: a task that is simply dropped
|
||||
// mid-flight, which is every in-flight task at shutdown.
|
||||
let claimed = claim_reconcile_retry(&id).expect("released above");
|
||||
let never_finishes = tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
never_finishes.abort();
|
||||
let _ = never_finishes.await;
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_some(),
|
||||
"a dropped task stranded the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,38 @@ pub async fn update_settings(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
// The global half of the same rule the project half gets in
|
||||
// `update_project`: a global custom env var is merged into every project's
|
||||
// container environment, so an unchecked name here reaches all of them.
|
||||
crate::models::validate_env_vars_update(
|
||||
&before.global_custom_env_vars,
|
||||
&settings.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// 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(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
|
||||
@@ -196,31 +196,64 @@ pub async fn upload_host_file_to_terminal(
|
||||
host_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||
// no traversal, and nothing whose path passes through a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. Four commands touch a host path now,
|
||||
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||
// open their dialog from Rust instead, so for them the policy above is
|
||||
// defence in depth and for these two it is the boundary itself.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||
// regular file, reports `len() == 0`, and passes both the directory check
|
||||
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. This is one of two routes for getting a host file into a
|
||||
// container (the Files pane's upload is the other), so it is the wrong
|
||||
// place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
} else {
|
||||
format!(
|
||||
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||
host_path
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||
// tar before upload, so cap the size of a dropped file.
|
||||
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
||||
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
||||
// with the code that does the reading, which re-applies it to the open
|
||||
// descriptor — this check is here only so the refusal reads like a sentence
|
||||
// instead of arriving after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
meta.len() as f64 / (1024.0 * 1024.0),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
|
||||
let base = std::path::Path::new(&host_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "dropped-file".to_string());
|
||||
|
||||
|
||||
// Ensure the destination directory exists rather than relying on Docker's
|
||||
// archive extractor to create the parent for the uploaded tar entry.
|
||||
@@ -231,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -283,3 +322,37 @@ pub async fn stop_audio_bridge(
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A dropped file must be named the way the *user* named it.
|
||||
///
|
||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||
/// name from the path *after* symlink resolution, so dropping
|
||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||
/// target's name. Nothing errored; the user just got a name they never
|
||||
/// typed.
|
||||
///
|
||||
/// This asserts the shared helper's contract from the terminal side: the
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
assert_eq!(
|
||||
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
||||
"latest.log"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/Downloads/").is_err(),
|
||||
"a directory is not a file to drop"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/..").is_err(),
|
||||
"the name becomes a tar entry, a container path and an argv element"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
+3348
-165
File diff suppressed because it is too large
Load Diff
@@ -301,21 +301,10 @@ impl ExecSessionManager {
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Build a tar archive in memory containing the file
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -333,40 +322,85 @@ impl ExecSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
///
|
||||
/// The file goes through host RAM twice — once as bytes, once inside the tar —
|
||||
/// so this is a memory bound, and it is checked against the *descriptor* that
|
||||
/// was opened rather than a `metadata` call that described whatever the path
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let data = std::fs::read(&host_path)
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, &dest_for_blk, &data[..])
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
&file,
|
||||
std::path::Path::new(&host_path),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(tar_buf)
|
||||
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
@@ -376,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -384,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -402,20 +446,10 @@ pub async fn upload_bytes_to_container(
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
|
||||
// is read back as root. The mtime still gets stamped so the file doesn't
|
||||
// read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -432,6 +466,74 @@ pub async fn upload_bytes_to_container(
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Build an in-memory tar archive holding a single regular file.
|
||||
///
|
||||
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
|
||||
/// them and Docker's archive extractor honours the header verbatim: a header
|
||||
/// left at the defaults lands the file inside the container as `root:root`
|
||||
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
|
||||
/// listing. Callers that upload on a user's behalf should pass the container
|
||||
/// user's ids from [`container_user_ids`].
|
||||
pub fn build_single_file_tar(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
uid: u64,
|
||||
gid: u64,
|
||||
mtime: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_uid(uid);
|
||||
header.set_gid(gid);
|
||||
header.set_mtime(mtime);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
Ok(tar_buf)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch, for a tar header mtime.
|
||||
pub fn now_epoch_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The numeric uid/gid of the container's `claude` user.
|
||||
///
|
||||
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
|
||||
/// ids on Unix so bind-mounted project files stay writable, and deliberately
|
||||
/// does not on Windows. So the only reliable answer comes from asking the
|
||||
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
let out = exec_oneshot_limited(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||
match (ids.next(), ids.next()) {
|
||||
(Some(uid), Some(gid)) => (uid, gid),
|
||||
_ => (1000, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||
/// host process.
|
||||
///
|
||||
@@ -450,14 +552,31 @@ pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
/// past anything genuine, far short of a problem.
|
||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
||||
/// limit is exceeded, at which point the caller must stop reading.
|
||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
||||
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||
///
|
||||
/// The byte count on its own is a fact about the transport, not about what the
|
||||
/// user did — "Command output exceeded 8388608 bytes" is not a sentence anybody
|
||||
/// can act on. A caller that knows what it was reading can recognise this and
|
||||
/// say the useful thing instead; see `list_container_files`, where the real
|
||||
/// cause is a directory with more entries than the panel can render.
|
||||
pub const OUTPUT_LIMIT_MARKER: &str = "OUTPUT_LIMIT";
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`, returning the range the chunk
|
||||
/// now occupies. `None` once the limit is exceeded, at which point the caller
|
||||
/// must stop reading — and nothing is appended, so a caller that ignored the
|
||||
/// answer cannot parse a half-read document.
|
||||
///
|
||||
/// Bytes rather than `str` on purpose: Docker frames a stream wherever it
|
||||
/// likes, so a chunk boundary can fall inside a UTF-8 sequence. Decoding each
|
||||
/// chunk on its own turned that into two replacement characters in the middle
|
||||
/// of a filename; the decode happens once, at the end, over the whole buffer.
|
||||
fn push_capped(buf: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Option<(usize, usize)> {
|
||||
if buf.len() + chunk.len() > limit {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
buf.push_str(chunk);
|
||||
true
|
||||
let start = buf.len();
|
||||
buf.extend_from_slice(chunk);
|
||||
Some((start, buf.len()))
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
@@ -521,6 +640,65 @@ pub async fn exec_oneshot_as(
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
/// every existing caller reads, and the right one for surfacing "why did that
|
||||
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||
/// caller that is *parsing* output can have just that without the buffer being
|
||||
/// held twice.
|
||||
struct OneshotOutput {
|
||||
combined: Vec<u8>,
|
||||
stdout_ranges: Vec<(usize, usize)>,
|
||||
exit_code: i64,
|
||||
}
|
||||
|
||||
impl OneshotOutput {
|
||||
/// Everything the command printed, in the order it printed it.
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.combined).into_owned()
|
||||
}
|
||||
|
||||
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||
/// mid-record is a parse error at best.
|
||||
fn stdout(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[*start..*end]);
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||
fn stderr(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[cursor..*start]);
|
||||
cursor = *end;
|
||||
}
|
||||
out.extend_from_slice(&self.combined[cursor..]);
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_as`] with the two streams kept apart, for callers that parse
|
||||
/// stdout.
|
||||
///
|
||||
/// `find`'s own diagnostics ("Permission denied") used to arrive inside the
|
||||
/// records its `-printf` was emitting. GNU `find` escapes tabs and newlines in
|
||||
/// those messages, so the listing parser held — but "the parser holds" is not
|
||||
/// the same as "the input is trustworthy", and the fix costs one enum match.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_inner(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
@@ -528,6 +706,17 @@ async fn exec_oneshot_inner(
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, limit).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_raw(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
@@ -550,22 +739,31 @@ async fn exec_oneshot_inner(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let mut combined = String::new();
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => {
|
||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
||||
if !push_capped(&mut combined, &chunk, limit) {
|
||||
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||
let bytes = data.into_bytes();
|
||||
match push_capped(&mut combined, &bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
return Err(format!(
|
||||
"Command output exceeded {} bytes and was abandoned",
|
||||
limit
|
||||
));
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
@@ -577,23 +775,60 @@ async fn exec_oneshot_inner(
|
||||
|
||||
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||
// final exit_code populated yet, so poll until the exec reports finished.
|
||||
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
|
||||
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
|
||||
Ok((combined, exit_code))
|
||||
Ok(OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||
///
|
||||
/// `unwrap_or(0)` is how a rename that never happened reported success: callers
|
||||
/// branch on `code != 0`, so an unreadable status silently became "it worked",
|
||||
/// the UI closed its rename box and the file had not moved. An exec whose
|
||||
/// outcome cannot be established has not been established to have succeeded —
|
||||
/// fail closed and let the caller surface it.
|
||||
///
|
||||
/// The `test -e` probe in `rename_container_path` also fails closed under this:
|
||||
/// it propagates the error instead of reading an undeterminable status as
|
||||
/// "the destination does not exist".
|
||||
fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||
code.ok_or_else(|| {
|
||||
"Could not determine whether the command finished (Docker did not report an exit status)"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||
/// doesn't report finished within ~1s — which shouldn't happen once its output
|
||||
/// doesn't report finished within ~5s — which shouldn't happen once its output
|
||||
/// stream has drained).
|
||||
///
|
||||
/// The window is generous because `None` is no longer a shrug: since
|
||||
/// [`require_exit_code`], it fails the whole call. Waiting a few seconds longer
|
||||
/// for a busy daemon to settle costs nothing in the normal case — the loop exits
|
||||
/// on the first poll that reports finished — and it is the difference between a
|
||||
/// spurious "the rename failed" and a real one.
|
||||
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
let docker = get_docker().ok()?;
|
||||
for _ in 0..40 {
|
||||
for _ in 0..200 {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
// Finished: use the reported code (default 0 if somehow absent).
|
||||
return Some(info.exit_code.unwrap_or(0));
|
||||
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||
// that has stopped without a reported code is a status
|
||||
// nobody can vouch for, and flattening it to *success* is
|
||||
// the wrong default when a caller is deciding whether to
|
||||
// rename a downloaded file over the user's own.
|
||||
// `download_container_file` treats `None` as a failure
|
||||
// precisely because it cannot tell that silence from a
|
||||
// clean exit; an `unwrap_or` here made that check
|
||||
// unreachable. Callers that only care about "did it fail
|
||||
// loudly" use `is_some_and`, which reads `None` as before.
|
||||
return info.exit_code;
|
||||
}
|
||||
}
|
||||
Err(_) => return None,
|
||||
@@ -607,31 +842,96 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||
let mut combined = Vec::new();
|
||||
let mut stdout_ranges = Vec::new();
|
||||
for (from_stdout, bytes) in frames {
|
||||
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||
if *from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_under_the_limit_is_buffered_whole() {
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "hello ", 16));
|
||||
assert!(push_capped(&mut buf, "world", 16));
|
||||
assert_eq!(buf, "hello world");
|
||||
let mut buf = Vec::new();
|
||||
assert_eq!(push_capped(&mut buf, b"hello ", 16), Some((0, 6)));
|
||||
assert_eq!(push_capped(&mut buf, b"world", 16), Some((6, 11)));
|
||||
assert_eq!(buf, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||
// The abandoned chunk must not land in the buffer either: a caller that
|
||||
// ignored the error would otherwise parse a half-read document.
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "0123456789", 12));
|
||||
assert!(!push_capped(&mut buf, "0123456789", 12));
|
||||
assert_eq!(buf, "0123456789");
|
||||
let mut buf = Vec::new();
|
||||
assert!(push_capped(&mut buf, b"0123456789", 12).is_some());
|
||||
assert!(push_capped(&mut buf, b"0123456789", 12).is_none());
|
||||
assert_eq!(buf, b"0123456789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_oversized_chunk_is_refused() {
|
||||
let mut buf = String::new();
|
||||
assert!(!push_capped(&mut buf, "0123456789", 4));
|
||||
let mut buf = Vec::new();
|
||||
assert!(push_capped(&mut buf, b"0123456789", 4).is_none());
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_split_across_two_frames_survives_the_decode() {
|
||||
// Docker frames a stream wherever it likes, and a filename is where
|
||||
// that shows: decoding each chunk on its own turned the two halves of
|
||||
// `ü` into two replacement characters in the middle of a name.
|
||||
let out = collect(&[(true, &[0xc3]), (true, &[0xbc, b'.', b't', b'x', b't'])]);
|
||||
assert_eq!(out.stdout(), "ü.txt");
|
||||
assert_eq!(out.text(), "ü.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagnostic_never_lands_in_the_stream_a_caller_parses() {
|
||||
// `find`'s "Permission denied" used to arrive inside the records its
|
||||
// `-printf` was emitting. Arrival order is still available for the
|
||||
// error message; the parser gets stdout alone.
|
||||
let out = collect(&[
|
||||
(true, b"first"),
|
||||
(false, b"find: /x: Permission denied\n"),
|
||||
(true, b"second"),
|
||||
]);
|
||||
assert_eq!(out.stdout(), "firstsecond");
|
||||
assert_eq!(out.stderr(), "find: /x: Permission denied\n");
|
||||
assert_eq!(out.text(), "firstfind: /x: Permission denied\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_limit_refusal_is_marked_so_a_caller_can_reword_it() {
|
||||
// "Command output exceeded 8388608 bytes" is a fact about a buffer.
|
||||
// The marker is what lets `list_container_files` say "too many entries"
|
||||
// instead, which is the thing that actually happened.
|
||||
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||
let refusal = format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||
);
|
||||
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeterminable_exit_status_is_an_error_not_a_zero() {
|
||||
// The bug this guards: `unwrap_or(0)` made every caller that branches on
|
||||
// `code != 0` — rename, mkdir — report success for an exec whose outcome
|
||||
// nobody could read.
|
||||
assert_eq!(require_exit_code(Some(0)).unwrap(), 0);
|
||||
assert_eq!(require_exit_code(Some(1)).unwrap(), 1);
|
||||
assert!(require_exit_code(None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
||||
@@ -640,4 +940,25 @@ mod tests {
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
|
||||
/// The reported path, which is what the terminal echoes back to Claude and
|
||||
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||
/// same file to the kernel and different strings to a person reading either
|
||||
/// of those.
|
||||
#[test]
|
||||
fn container_join_produces_one_separator() {
|
||||
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||
// The terminal's drop passes a nested name; it must not gain a second
|
||||
// slash at the seam.
|
||||
assert_eq!(
|
||||
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
// A directory the user navigated to can carry a trailing slash, and the
|
||||
// container root is the case where trimming it must not eat the only
|
||||
// separator there is.
|
||||
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[
|
||||
("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"),
|
||||
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
||||
("/opt/mission-control", "Mission Control (Flight Control)"),
|
||||
("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
|
||||
("/opt/triple-c-skills", "Bundled skills for the VPN Support toggle (PIA VPN)"),
|
||||
];
|
||||
|
||||
/// Headroom demanded on Docker's storage backend on top of the measured
|
||||
@@ -367,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||
let mut out: Vec<String> = paths
|
||||
.iter()
|
||||
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||
// That function skips a row with an empty `host_path` or `mount_name`
|
||||
// so a legacy row cannot brick the create. The consequence is that
|
||||
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||
// ordinary writable-layer content. Excluding it here would tell
|
||||
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||
// would then destroy whatever the user has put there. The two
|
||||
// predicates must agree or a migration silently eats a directory.
|
||||
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -710,13 +721,83 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
||||
let id = created.id;
|
||||
|
||||
let result = run_throwaway_inner(&id).await;
|
||||
// From here on the container's removal is owned by a guard rather than by
|
||||
// the statement that used to sit after the await below. A plain statement
|
||||
// only runs if this future is *polled to completion*: an `Err(...)?` was
|
||||
// already handled, but a **dropped** future — the app quitting mid-flight,
|
||||
// a timeout, any `select!` that loses — skipped it silently and left a
|
||||
// container behind holding a multi-gigabyte base image open. That image is
|
||||
// then unsweepable (removal is deliberately unforced) and there is nothing
|
||||
// in the UI that would ever mention it.
|
||||
let guard = ProbeContainerGuard::new(created.id);
|
||||
|
||||
if let Err(e) = docker
|
||||
let result = run_throwaway_inner(guard.id()).await;
|
||||
|
||||
// The happy path still removes it *synchronously*, so a caller that goes on
|
||||
// to `docker rmi` the image it probed does not race the removal.
|
||||
guard.remove_now().await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Owns the lifetime of a probe container.
|
||||
///
|
||||
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
|
||||
/// safety net for the abnormal one: it cannot await, so it hands the removal to
|
||||
/// a detached task. That covers a dropped future while the process lives; it
|
||||
/// cannot cover the process dying, which is what
|
||||
/// [`reap_probe_containers`] is for.
|
||||
struct ProbeContainerGuard {
|
||||
id: String,
|
||||
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl ProbeContainerGuard {
|
||||
fn new(id: String) -> Self {
|
||||
Self { id, armed: true }
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// **Disarm after the await, never before it.** Clearing `armed` first
|
||||
/// looked equivalent and was the exact inverse of this guard's purpose: on
|
||||
/// the one path it exists for — this future being dropped part-way through
|
||||
/// the removal — `Drop` then saw a disarmed guard and did nothing, so the
|
||||
/// container survived with no background removal queued behind it. Setting
|
||||
/// it afterwards means a cancelled `remove_now` falls back to `Drop`'s
|
||||
/// detached removal, and only a removal that actually completed disarms.
|
||||
async fn remove_now(mut self) {
|
||||
remove_probe_container(&self.id).await;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProbeContainerGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let id = std::mem::take(&mut self.id);
|
||||
log::warn!("Probe container {} was abandoned; removing it in the background", id);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
remove_probe_container(&id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-remove one probe container. Missing is success — the point is that the
|
||||
/// container is gone.
|
||||
async fn remove_probe_container(id: &str) {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
match docker
|
||||
.remove_container(
|
||||
&id,
|
||||
id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
v: true,
|
||||
@@ -725,12 +806,96 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to remove probe container {}: {}", id, e);
|
||||
Ok(())
|
||||
| Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {}
|
||||
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove probe containers left behind by a previous run of the app.
|
||||
///
|
||||
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
|
||||
/// crash, but until now nothing ever went looking. One leftover probe pins the
|
||||
/// base image it was created from — several gigabytes that
|
||||
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
|
||||
/// touch, with no way for the user to find out why.
|
||||
///
|
||||
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
|
||||
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
|
||||
/// right now it belongs to this process — and this runs before any migration
|
||||
/// can be started, so there is none to interrupt.
|
||||
///
|
||||
/// **Except that "belongs to this process" is not something this can know.**
|
||||
/// The filter is a label, and labels are daemon-wide: a second copy of the app
|
||||
/// migrating a project on the same daemon has probe containers carrying exactly
|
||||
/// this label, and force-removing one mid-manifest-capture fails that
|
||||
/// migration. In-process state cannot see the other instance, so the only
|
||||
/// available brake is age — [`PROBE_REAP_MIN_AGE_SECS`]. A probe runs a `df`, an
|
||||
/// `apt-get update` or a `find` over a root filesystem; none of those is a
|
||||
/// multi-minute job, so anything younger than the gate is far more likely to be
|
||||
/// someone's live probe than a leftover, and a leftover simply waits for the
|
||||
/// next start.
|
||||
pub async fn reap_probe_containers() {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"label".to_string(),
|
||||
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
|
||||
)]);
|
||||
|
||||
let containers = match docker
|
||||
.list_containers(Some(bollard::container::ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe containers: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for c in containers {
|
||||
// `created` is a unix timestamp; a summary without one is treated as
|
||||
// too young to touch, because unknown is never permission.
|
||||
let age = c.created.map(|created| now - created);
|
||||
match age {
|
||||
Some(age) if age >= PROBE_REAP_MIN_AGE_SECS => {}
|
||||
_ => {
|
||||
log::info!(
|
||||
"Leaving migration probe container {} alone — it is younger than {} minutes, \
|
||||
so it may belong to another Triple-C instance's live migration",
|
||||
c.id.as_deref().unwrap_or("<unknown>"),
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(id) = c.id {
|
||||
log::info!("Removing leftover migration probe container {}", id);
|
||||
remove_probe_container(&id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
/// The label is daemon-wide and this process cannot tell its own leftovers from
|
||||
/// another instance's live probe, so this is the whole guard. Generous against
|
||||
/// the longest probe there is (an `apt-get update` inside a throwaway container
|
||||
/// on a slow link) and still short enough that a crashed run's probe stops
|
||||
/// pinning a multi-gigabyte base image within the hour.
|
||||
pub const PROBE_REAP_MIN_AGE_SECS: i64 = 30 * 60;
|
||||
|
||||
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -908,6 +1073,210 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
|
||||
}
|
||||
|
||||
/// How long a rollback pin may sit with no migration record behind it before
|
||||
/// [`reap_stale_migration_pins`] drops the tag.
|
||||
///
|
||||
/// Two weeks, chosen to be far longer than anyone deliberates over a base
|
||||
/// update and far shorter than "forever", which is what it was.
|
||||
///
|
||||
/// **Measured from when the record went missing, not from the tag.** See
|
||||
/// [`pin_is_reapable`] and
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`].
|
||||
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
|
||||
|
||||
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
|
||||
///
|
||||
/// `None` for anything that is not one of ours — a tag that merely *starts*
|
||||
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
|
||||
/// rather than guessed at, because the consequence of guessing wrong is
|
||||
/// deleting the only copy of somebody's system layer.
|
||||
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let stamp = tag.strip_prefix("pre-migration-")?;
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
|
||||
Some(naive.and_utc())
|
||||
}
|
||||
|
||||
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
|
||||
///
|
||||
/// `None` when the reference is not a snapshot repo at all.
|
||||
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
|
||||
let (repo, tag) = split_image_ref(reference);
|
||||
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
|
||||
if project_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((project_id, tag))
|
||||
}
|
||||
|
||||
/// Whether a rollback pin is safe to drop, given whether the project it belongs
|
||||
/// to still has a migration record and how long it has been without one.
|
||||
///
|
||||
/// Pure so the decision can be tested without a daemon. The order of the
|
||||
/// conditions is the point: **a pin whose migration is still awaiting
|
||||
/// confirmation is never reaped at any age**, because it is the only copy of
|
||||
/// the rollback target and the user has not yet said they are happy with the
|
||||
/// new base.
|
||||
///
|
||||
/// ## `ownerless_since`, and why it is not the tag's timestamp
|
||||
///
|
||||
/// This used to compute the age from `parse_rollback_tag(tag)` — the instant
|
||||
/// the migration *started*. A migration is allowed to sit at
|
||||
/// `awaiting-confirmation` for as long as the user likes; that is what
|
||||
/// `keep_rollback` is for. A project parked there for a month whose record is
|
||||
/// then lost had a tag a month old, so the pin was reapable on the very next
|
||||
/// check and the startup sweep deleted the image immediately after. The
|
||||
/// fourteen days were nominal: the real grace period for the case the constant
|
||||
/// was written for was zero.
|
||||
///
|
||||
/// So the clock starts when the claim was lost, which is recorded by
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`] the first time a
|
||||
/// reaper notices. `None` means no reaper has recorded a sighting yet, and that
|
||||
/// is **not** "sighted now": returning false there is what gives a pin its
|
||||
/// first full fourteen days instead of none.
|
||||
///
|
||||
/// ## Clock skew
|
||||
///
|
||||
/// A `now` earlier than `ownerless_since` — a host clock that ran fast and was
|
||||
/// corrected, or a data directory carried between machines — yields a negative
|
||||
/// elapsed time. That is treated as not reapable, and the marker writer
|
||||
/// re-anchors it, rather than letting a negative `num_days()` mean "never" or
|
||||
/// an inflated one mean "immediately".
|
||||
pub fn pin_is_reapable(
|
||||
tag: &str,
|
||||
has_migration_record: bool,
|
||||
ownerless_since: Option<chrono::DateTime<chrono::Utc>>,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
if has_migration_record {
|
||||
return false;
|
||||
}
|
||||
// Still required: the tag has to be one of ours. A hand-made
|
||||
// `pre-migration-keepme` is somebody's deliberate pin and is never guessed
|
||||
// at, whatever a marker beside it says.
|
||||
if parse_rollback_tag(tag).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(since) = ownerless_since else {
|
||||
return false;
|
||||
};
|
||||
let elapsed = *now - since;
|
||||
elapsed >= chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS)
|
||||
}
|
||||
|
||||
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
|
||||
/// claims any more, so the images behind them become sweepable.
|
||||
///
|
||||
/// ## Why this scans tags instead of reading the records
|
||||
///
|
||||
/// Every other path to a rollback pin starts from
|
||||
/// `migration_store::load`, and `load` reports an unparseable state file as
|
||||
/// *absent* — so a single corrupt record used to strand a 4–12 GB image that no
|
||||
/// code could ever name again. Confirming or rolling back both remove the
|
||||
/// record and drop the tag together, so a `pre-migration-*` tag with no record
|
||||
/// beside it is by definition one that lost its owner: a crash between the two,
|
||||
/// a record that was deleted by hand, or the corrupt-file case.
|
||||
///
|
||||
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
|
||||
/// corrupt record aside (see `migration_store::load`) is what stops that case
|
||||
/// from being permanently invisible here too.
|
||||
///
|
||||
/// ## Why it only untags
|
||||
///
|
||||
/// Dropping the tag turns the image dangling, and it is already labelled
|
||||
/// `triple-c.managed=true` because `docker commit` created it — so
|
||||
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
|
||||
/// safety conditions, with the daemon's "still in use by a container" refusal
|
||||
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
|
||||
pub async fn reap_stale_migration_pins() -> usize {
|
||||
use bollard::image::ListImagesOptions;
|
||||
|
||||
let Ok(docker) = get_docker() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// `reference` matches against `repo:tag`, so this asks the daemon for
|
||||
// exactly the shape [`rollback_tag`] produces and nothing else.
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
|
||||
)]);
|
||||
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list rollback pins: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut reaped = 0usize;
|
||||
|
||||
for summary in images {
|
||||
for reference in &summary.repo_tags {
|
||||
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
|
||||
continue;
|
||||
};
|
||||
// Filesystem presence, not `load`: a record we cannot parse must
|
||||
// still count as "somebody may want this back".
|
||||
let has_record =
|
||||
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
|
||||
if has_record {
|
||||
// Owned again (or still owned): throw away any grace clock a
|
||||
// previous pass started, so a pin that loses its record twice
|
||||
// gets a fresh fourteen days rather than inheriting a stale one.
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
continue;
|
||||
}
|
||||
// Only a *well-formed* pin gets a marker written for it — a tag
|
||||
// that is not one of ours is left entirely alone, files included.
|
||||
if parse_rollback_tag(&tag).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Records the first sighting when there is none, which is why this
|
||||
// returns `None` on that pass and the pin survives it.
|
||||
//
|
||||
// A `save` can land between the `has_record` above and this write,
|
||||
// which would plant a tombstone dated *now* behind a perfectly
|
||||
// valid record — invisible until that record is legitimately lost,
|
||||
// at which point the pin is already past its grace period and is
|
||||
// reaped on the first check. `note_ownerless_since` re-asks
|
||||
// `has_record` after the write and removes the marker again; the
|
||||
// reasoning for why that closes the window is on it.
|
||||
let ownerless_since =
|
||||
crate::storage::migration_store::note_ownerless_since(&project_id, &tag, &now);
|
||||
if !pin_is_reapable(&tag, has_record, ownerless_since, &now) {
|
||||
continue;
|
||||
}
|
||||
match untag_image(reference).await {
|
||||
Ok(()) => {
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
log::info!(
|
||||
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it since {}, more than {} days",
|
||||
reference,
|
||||
summary.size as f64 / 1_073_741_824.0,
|
||||
ownerless_since
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
STALE_PIN_MAX_AGE_DAYS,
|
||||
);
|
||||
reaped += 1;
|
||||
}
|
||||
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reaped
|
||||
}
|
||||
|
||||
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||
match image.rsplit_once(':') {
|
||||
@@ -1008,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The mount filter and the migration's exclusion list must agree.
|
||||
///
|
||||
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||
/// writable-layer content rather than a bind mount — and if this function
|
||||
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||
/// the container swap would destroy whatever is there. A migration eating a
|
||||
/// directory is the quietest kind of data loss there is.
|
||||
#[test]
|
||||
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||
let paths = vec![
|
||||
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||
// Legacy shapes that `project_path_mounts` skips.
|
||||
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||
];
|
||||
let excluded = bind_mount_exclusions(&paths);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec!["/workspace/code".to_string()],
|
||||
"only rows that are actually mounted may be excluded from staging"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
use crate::models::{
|
||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||
@@ -1620,4 +2013,137 @@ mod tests {
|
||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||
}
|
||||
|
||||
|
||||
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||
|
||||
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rollback_tag_round_trips_through_its_parser() {
|
||||
let made = at(2026, 3, 14);
|
||||
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_rollback_tag_parses() {
|
||||
assert_eq!(parse_rollback_tag("latest"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-"), None);
|
||||
// Looks like ours but carries no timestamp we produced. Guessing here
|
||||
// would mean deleting the only copy of somebody's system layer.
|
||||
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_reference_yields_its_project_id() {
|
||||
assert_eq!(
|
||||
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
|
||||
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
|
||||
);
|
||||
// Not ours: a base image, and a repo that merely shares a prefix.
|
||||
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
|
||||
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
|
||||
// The one rule that cannot bend: while a migration record exists, this
|
||||
// image is the only copy of the rollback target and the user has not
|
||||
// yet said they are happy on the new base.
|
||||
let ancient = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(
|
||||
&ancient,
|
||||
true,
|
||||
Some(at(2020, 1, 1)),
|
||||
&at(2026, 8, 23)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
|
||||
let tag = rollback_tag(&at(2026, 8, 1));
|
||||
// The clock runs from when the record went missing, which here is well
|
||||
// after the migration started.
|
||||
let lost = at(2026, 8, 10);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(lost), &at(2026, 8, 11)));
|
||||
assert!(!pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS) - chrono::Duration::seconds(1))
|
||||
));
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grace_period_runs_from_the_lost_record_not_from_the_tag() {
|
||||
// The bug this replaced, stated as a test. A migration parked at
|
||||
// `awaiting-confirmation` for a month — supported, that is what
|
||||
// `keep_rollback` is for — whose record is then lost had a
|
||||
// month-old tag, so the old rule made its pin reapable on the very
|
||||
// next app start with the startup sweep deleting the image two lines
|
||||
// later. Zero grace, on the one case the fourteen days exist for.
|
||||
let started = at(2026, 6, 1);
|
||||
let tag = rollback_tag(&started);
|
||||
let record_lost = at(2026, 7, 1);
|
||||
let noticed_immediately_after = record_lost + chrono::Duration::minutes(5);
|
||||
assert!(
|
||||
!pin_is_reapable(&tag, false, Some(record_lost), ¬iced_immediately_after),
|
||||
"a tag a month old must still get its full grace period once orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsighted_pin_is_never_reaped_on_the_pass_that_first_sees_it() {
|
||||
// `None` means no reaper has recorded a sighting. Treating that as
|
||||
// "sighted now" would be harmless; treating it as "sighted long ago"
|
||||
// would not, and neither is what it means — the marker is written on
|
||||
// this pass and the pin becomes reapable fourteen days later.
|
||||
let tag = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2026, 8, 23)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_that_ran_backwards_neither_reaps_nor_strands() {
|
||||
// A marker dated after `now`: the host clock was fast and got
|
||||
// corrected, or the data directory came from another machine. A
|
||||
// negative elapsed time must read as "not yet", not as a huge age.
|
||||
let tag = rollback_tag(&at(2026, 1, 1));
|
||||
let marker = at(2026, 9, 1);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(marker), &at(2026, 8, 1)));
|
||||
// The other direction is bounded by the marker rather than by the tag:
|
||||
// a wildly future `now` can only expire a clock that was actually
|
||||
// started, and a pin with no marker (the case above) still cannot be
|
||||
// reaped at all — which is what stops a fast host clock from making
|
||||
// *every* pin on the daemon instantly collectable.
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(at(2026, 8, 20)),
|
||||
&at(2030, 1, 1)
|
||||
));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2030, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_we_cannot_date_is_left_alone() {
|
||||
// Even with an ancient ownerless marker sitting beside it: a tag that
|
||||
// merely *starts* `pre-migration-` is somebody's deliberate pin, and
|
||||
// the reaper never writes a marker for one in the first place.
|
||||
let ancient = Some(at(2020, 1, 1));
|
||||
let now = at(2026, 8, 23);
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
}
|
||||
|
||||
+281
-4
@@ -5,6 +5,7 @@ mod docker;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod web_terminal;
|
||||
|
||||
@@ -212,7 +213,6 @@ pub fn run() {
|
||||
let lifecycle_setup = lifecycle.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
@@ -235,6 +235,40 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Startup disk housekeeping ────────────────────────────────
|
||||
// Until now the only sweep ran *after* a recreation, so a user who
|
||||
// simply stopped launching a project kept its orphaned snapshot
|
||||
// layers forever, and anything a crash left behind (a probe
|
||||
// container pinning a base image, a rollback pin whose migration
|
||||
// record is gone) had no path back at all. All of it is
|
||||
// read-mostly and finishes in well under a second on an idle daemon,
|
||||
// but they are detached anyway: housekeeping must never delay the
|
||||
// window appearing, and a daemon that is not running yet is a
|
||||
// logged warning rather than a failed start.
|
||||
//
|
||||
// Ordering matters. Probes are removed first because a probe holds
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
// A container/image/volume `remove_project` could not delete
|
||||
// is recorded rather than lost — see triple-c#31 — and this is
|
||||
// the only place anything ever retries it. Takes the store so
|
||||
// it can refuse to touch a project that turns out to still be
|
||||
// live — see the long comment on the function itself.
|
||||
crate::commands::project_commands::retry_pending_cleanup_logged(
|
||||
&projects_store_for_cleanup,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
let settings = settings_store_setup.get();
|
||||
if settings.web_terminal.enabled {
|
||||
@@ -411,7 +445,6 @@ pub fn run() {
|
||||
commands::docker_commands::check_image_exists,
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
@@ -452,6 +485,7 @@ pub fn run() {
|
||||
commands::auth_token_commands::cancel_claude_token,
|
||||
commands::auth_token_commands::has_claude_token,
|
||||
commands::auth_token_commands::clear_claude_token,
|
||||
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -472,9 +506,12 @@ pub fn run() {
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_files_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
@@ -652,4 +689,244 @@ mod tests {
|
||||
lifecycle.settle_startup_tasks().await;
|
||||
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// The capability file is the app's entire IPC attack surface, and it is
|
||||
/// data — nothing in `cargo test` reads it, so a widened grant lands with a
|
||||
/// green suite. This is what noticing looks like.
|
||||
///
|
||||
/// It exists because `core:default` was granted for months. That alias
|
||||
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
||||
/// unconditional `std::fs::read` of any host path with no scope check, and
|
||||
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
||||
/// Every `#[tauri::command]` is registered, and every registration names a
|
||||
/// command that exists.
|
||||
///
|
||||
/// This is the shape of the bug that caused the original OAuth-callback
|
||||
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
||||
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
||||
/// users to flip was never wired to anything, so the bridge stayed off and
|
||||
/// every login callback was refused. Nothing failed; the feature was simply
|
||||
/// absent, and no test noticed because both halves compiled.
|
||||
///
|
||||
/// The reverse direction matters too, and for a sharper reason: a command
|
||||
/// that is registered but reachable from nowhere is still IPC surface a
|
||||
/// compromised webview can call. `list_sibling_containers` — which returned
|
||||
/// every container on the daemon, including the user's unrelated work —
|
||||
/// sat in exactly that state, and this test is what found it. It has since
|
||||
/// been removed at all four levels: registration, command, docker helper,
|
||||
/// and the frontend wrapper and type.
|
||||
///
|
||||
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
||||
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
||||
/// vitest suite cover that side.
|
||||
#[test]
|
||||
fn every_command_is_registered_exactly_once() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// Walk the source tree for the command attribute and take the `fn` name
|
||||
// that follows.
|
||||
//
|
||||
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||
// Every one of those is a command the frontend could not call, which is
|
||||
// the bug this test exists for, and the test stayed green.
|
||||
//
|
||||
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||
// pass, while on the *registration* side it fails loudly against
|
||||
// legitimate code — and rustc already covers that direction. So this
|
||||
// errs toward over-matching definitions.
|
||||
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim();
|
||||
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||
// `#[tauri :: command]`, a bare `#[command]` under
|
||||
// `use tauri::command`, and any of those with a
|
||||
// trailing comment.
|
||||
let attr = t.strip_prefix("#[").map(|a| {
|
||||
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||
});
|
||||
let is_command_attr = attr.is_some_and(|a| {
|
||||
a == "command" || a == "tauri::command"
|
||||
|| a.starts_with("command(")
|
||||
|| a.starts_with("tauri::command(")
|
||||
});
|
||||
if !is_command_attr {
|
||||
continue;
|
||||
}
|
||||
// Skip further attributes and doc comments rather than
|
||||
// giving up at the first line that is not an attribute.
|
||||
for next in lines.iter().skip(i + 1) {
|
||||
let t = next.trim();
|
||||
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any visibility, then `fn` or `async fn`.
|
||||
let after_vis = t
|
||||
.strip_prefix("pub(crate) ")
|
||||
.or_else(|| t.strip_prefix("pub(super) "))
|
||||
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||
.or_else(|| t.strip_prefix("pub "))
|
||||
.unwrap_or(t);
|
||||
let after_async =
|
||||
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||
if let Some(name) = rest.split(['(', '<']).next() {
|
||||
out.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(
|
||||
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
||||
&mut defined,
|
||||
);
|
||||
|
||||
// The registration list, read from this file rather than from a macro
|
||||
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||
let this = include_str!("lib.rs");
|
||||
let handler = this
|
||||
.split_once("generate_handler![")
|
||||
.and_then(|(_, rest)| rest.split_once("])"))
|
||||
.map(|(inside, _)| inside)
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||
// style comments, and splitting on commas glues each comment to the
|
||||
// command that follows it. A `starts_with("//")` filter then drops that
|
||||
// command — silently, and once per group.
|
||||
let registered: BTreeSet<String> = handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
.filter_map(|l| {
|
||||
l.trim_end_matches(',')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.map(|n| n.trim().to_string())
|
||||
})
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!defined.is_empty() && !registered.is_empty(),
|
||||
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
||||
defined.len(),
|
||||
registered.len()
|
||||
);
|
||||
|
||||
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
||||
assert!(
|
||||
unregistered.is_empty(),
|
||||
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
||||
unregistered
|
||||
);
|
||||
|
||||
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
||||
assert!(
|
||||
undefined.is_empty(),
|
||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||
undefined
|
||||
);
|
||||
|
||||
// "exactly once" was in this test's name and not in its body: both
|
||||
// sides were sets, so registering the same command twice in a
|
||||
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||
// passed here.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut duplicated: Vec<&str> = Vec::new();
|
||||
for line in handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
{
|
||||
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&name) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicated.is_empty(),
|
||||
"these are registered more than once: {:?}",
|
||||
duplicated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
||||
let raw = include_str!("../capabilities/default.json");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(raw).expect("capabilities/default.json must parse");
|
||||
let listed: Vec<String> = parsed["permissions"]
|
||||
.as_array()
|
||||
.expect("a `permissions` array")
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
// A scoped grant is an object; its identifier is what matters here.
|
||||
serde_json::Value::Object(o) => o["identifier"]
|
||||
.as_str()
|
||||
.expect("a scoped grant needs an identifier")
|
||||
.to_string(),
|
||||
other => other.as_str().expect("a grant is a string or an object").to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut sorted = listed.clone();
|
||||
sorted.sort();
|
||||
let mut expected = vec![
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
sorted, expected,
|
||||
"the capability set changed. That is allowed — but it is the IPC \
|
||||
surface a compromised webview can call, so update this list \
|
||||
deliberately rather than to make the test pass."
|
||||
);
|
||||
|
||||
// Belt and braces: the `*:default` aliases are the specific trap here,
|
||||
// because they expand to a set the file never spells out. `store:*` in
|
||||
// particular was an arbitrary host-file read/write primitive.
|
||||
for grant in &listed {
|
||||
assert!(
|
||||
!grant.ends_with(":default"),
|
||||
"{} is an alias — it expands to permissions this file does not \
|
||||
name. Enumerate them instead.",
|
||||
grant
|
||||
);
|
||||
assert!(
|
||||
!grant.starts_with("store:"),
|
||||
"store:* is `PathBuf::push` against AppData, which an absolute \
|
||||
path discards: an arbitrary host-file read/write."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The level the dispatch is built with, and the level restored by hand if
|
||||
/// installing it fails — see the failure branch in [`init`] for why that
|
||||
/// matters more than it looks.
|
||||
const LOG_LEVEL: log::LevelFilter = log::LevelFilter::Info;
|
||||
|
||||
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||
@@ -33,7 +38,7 @@ pub fn init() {
|
||||
message
|
||||
))
|
||||
})
|
||||
.level(log::LevelFilter::Info)
|
||||
.level(LOG_LEVEL)
|
||||
.chain(std::io::stderr());
|
||||
|
||||
if let Some((_path, file)) = &log_file_path {
|
||||
@@ -41,7 +46,28 @@ pub fn init() {
|
||||
}
|
||||
|
||||
if let Err(e) = dispatch.apply() {
|
||||
eprintln!("Failed to initialise logger: {}", e);
|
||||
// H2's other half. `fern::Dispatch::apply` calls `log::set_boxed_logger`
|
||||
// and only then `log::set_max_level`, so a failure returns with the
|
||||
// global filter still at its default, `LevelFilter::Off`. That is not
|
||||
// merely "no log output": every `log::info!(…)` expands to
|
||||
// `if Info <= max_level() { … }`, so at `Off` the macro never evaluates
|
||||
// its own arguments. Anything a call site put in an argument list —
|
||||
// a function call, an `await`, a side effect — silently stops
|
||||
// happening, app-wide, because a logger could not be installed.
|
||||
//
|
||||
// Call sites must not put effects in log arguments (see the
|
||||
// pre-migration scrub in `migration_commands.rs`), but "the whole
|
||||
// program's log macros are dead and nothing said so" is its own
|
||||
// hazard, so the level this dispatch was configured with is restored
|
||||
// by hand. Nothing is listening — `log`'s default logger is a no-op —
|
||||
// but the macros evaluate, and the one thing that *is* guaranteed to
|
||||
// reach the user, the stderr line below, says what happened.
|
||||
eprintln!(
|
||||
"Failed to initialise logger: {}. Log output is disabled for this run; \
|
||||
log macros still evaluate their arguments.",
|
||||
e
|
||||
);
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
}
|
||||
|
||||
// Install a panic hook that writes to the log file so crashes are captured.
|
||||
@@ -71,3 +97,40 @@ pub fn init() {
|
||||
log::info!("Logging to {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_logger_that_could_not_be_installed_still_leaves_the_macros_evaluating() {
|
||||
// H2: `log::info!(…)` expands to `if Info <= max_level() { … }`, so at
|
||||
// `LevelFilter::Off` the arguments are never evaluated. `fern` returns
|
||||
// before `set_max_level` when `apply()` fails, which leaves exactly
|
||||
// that state — and a call site that folded an effect into an argument
|
||||
// list then stops performing it, app-wide, because a log file could not
|
||||
// be opened. The failure branch restores the level for that reason.
|
||||
//
|
||||
// Asserted on the level itself rather than by driving `init`, which
|
||||
// installs a process-global logger and a panic hook and can only run
|
||||
// once per process.
|
||||
assert_ne!(LOG_LEVEL, log::LevelFilter::Off);
|
||||
|
||||
// The property that makes the above worth asserting, demonstrated
|
||||
// against the macro itself: a side effect in an argument list runs only
|
||||
// while the level admits the record.
|
||||
let mut ran = false;
|
||||
let effect = |v: &mut bool| {
|
||||
*v = true;
|
||||
0
|
||||
};
|
||||
let previous = log::max_level();
|
||||
log::set_max_level(log::LevelFilter::Off);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(!ran, "the premise is wrong: arguments evaluated at LevelFilter::Off");
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(ran, "arguments did not evaluate at the level this module configures");
|
||||
log::set_max_level(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone. That includes
|
||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||
/// safe direction to be wrong in.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
/// call. Narrow in practice (an already-running browser just receives the
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// ## Why a charset rule, and not just the reserved-name list
|
||||
///
|
||||
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||
/// that is not an identifier travels through unchallenged.
|
||||
///
|
||||
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||
/// exported shell function: bash imports those at startup and the *body* is the
|
||||
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||
/// code execution inside the container at commit time.
|
||||
///
|
||||
/// So the rule is the shape of the thing rather than a list of the names that
|
||||
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||
/// a `%`, a `(` or a space in an environment variable name.
|
||||
///
|
||||
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||
/// break a setting that works.
|
||||
pub fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.trim().chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Validate a custom environment variable list that is about to be stored,
|
||||
/// admitting the entries it is already stored with.
|
||||
///
|
||||
/// Same shape, and the same reasoning, as
|
||||
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||
/// a project unsavable *entirely* — `update_project` is the single command
|
||||
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||
/// is already being handed to every container that starts. An entry carried
|
||||
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||
/// key through this command.
|
||||
///
|
||||
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||
/// second copy of an existing entry is a new entry.
|
||||
///
|
||||
/// The blank entry is not a violation. "+ Add variable" appends
|
||||
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||
/// turn the button itself into an error toast; `create_container` skips an
|
||||
/// empty key, so it reaches nothing.
|
||||
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||
// An entry with no key is the placeholder, whatever is in its value:
|
||||
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||
// to refuse. The editor saves on every blur, and typing the value before
|
||||
// the name is an ordinary way to fill a row in.
|
||||
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||
|
||||
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||
std::collections::HashMap::new();
|
||||
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||
*carried
|
||||
.entry((v.key.as_str(), v.value.as_str()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||
Some(remaining) if *remaining > 0 => {
|
||||
*remaining -= 1;
|
||||
}
|
||||
_ => {
|
||||
if !is_valid_env_key(&v.key) {
|
||||
return Err(format!(
|
||||
"'{}' is not a usable environment variable name. Use a letter or \
|
||||
underscore followed by letters, digits or underscores.",
|
||||
v.key
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectPath {
|
||||
pub host_path: String,
|
||||
@@ -85,31 +179,141 @@ impl PermissionMode {
|
||||
/// Settings for Claude Code CLI behavior inside the container.
|
||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(from = "StoredClaudeCodeSettings")]
|
||||
/// Every field is three-state, and the third state is load-bearing.
|
||||
///
|
||||
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||
/// whatever the global settings say"; for the *global* settings it is "leave
|
||||
/// Claude Code's own default alone". `Some(false)` is a deliberate off, which
|
||||
/// is what lets a project turn a globally-enabled setting back off — with a
|
||||
/// plain `bool` there is no value that can express that, which is why these
|
||||
/// were widened from `bool`.
|
||||
pub struct ClaudeCodeSettings {
|
||||
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
||||
#[serde(default)]
|
||||
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
|
||||
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||
/// one. All three are distinct — "let it choose" is not "classic".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tui_mode: Option<String>,
|
||||
/// Effort level: None = default, Some("low"|"medium"|"high")
|
||||
#[serde(default)]
|
||||
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Disable auto-scroll in fullscreen TUI mode
|
||||
#[serde(default)]
|
||||
pub auto_scroll_disabled: bool,
|
||||
/// Enable focus mode (collapsed tool output)
|
||||
#[serde(default)]
|
||||
pub focus_mode: bool,
|
||||
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||
/// zero value of this field has to mean "leave it on".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_scroll_disabled: Option<bool>,
|
||||
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus_mode: Option<bool>,
|
||||
/// Show thinking summaries in responses
|
||||
#[serde(default)]
|
||||
pub show_thinking_summaries: bool,
|
||||
/// Enable session recap when returning to a session
|
||||
#[serde(default)]
|
||||
pub enable_session_recap: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_thinking_summaries: Option<bool>,
|
||||
/// Turn the session recap **off**.
|
||||
///
|
||||
/// Held in the disabled sense for the same reason as `auto_scroll_disabled`,
|
||||
/// and the rename from the old `enable_session_recap` is load-bearing rather
|
||||
/// than cosmetic. Claude Code's recap is on by default, so the old field was
|
||||
/// inverted: switching it on was a no-op and switching it off did nothing at
|
||||
/// all. Reusing the name with the opposite meaning would have read every
|
||||
/// stored `enable_session_recap: false` — which is what every project that
|
||||
/// never touched the control holds — as "the user turned the recap off" and
|
||||
/// silently disabled it for all of them. A new name lets the old key be
|
||||
/// ignored, which lands every existing project on the correct default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_recap_disabled: Option<bool>,
|
||||
/// Strip credentials from subprocess environments
|
||||
#[serde(default)]
|
||||
pub env_scrub: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_scrub: Option<bool>,
|
||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_caching_1h: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||
/// through.
|
||||
///
|
||||
/// ## The upgrade this exists to survive
|
||||
///
|
||||
/// Before the widening, the five booleans were plain `bool`s with
|
||||
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||
/// information at all: it was the only value an unset switch could produce, and
|
||||
/// the global always won.
|
||||
///
|
||||
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||
/// credentials from subprocess environments") among them. There is no store
|
||||
/// migration anywhere: `projects_store` parses these structs directly.
|
||||
///
|
||||
/// ## How an old record is told apart from a new one
|
||||
///
|
||||
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||
/// so the old key could be ignored (see the doc on that field), and the new
|
||||
/// code has never written it. Its presence is therefore an exact statement that
|
||||
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||
/// booleans are read back that way: `true` is a real choice and survives,
|
||||
/// `false` becomes `None` and inherits again.
|
||||
///
|
||||
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||
/// an older binary reads an absent key as `false` through its own
|
||||
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||
/// and starts empty on an error.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredClaudeCodeSettings {
|
||||
#[serde(default)]
|
||||
pub prompt_caching_1h: bool,
|
||||
tui_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
effort: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_scroll_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
focus_mode: Option<bool>,
|
||||
#[serde(default)]
|
||||
show_thinking_summaries: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_recap_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
env_scrub: Option<bool>,
|
||||
#[serde(default)]
|
||||
prompt_caching_1h: Option<bool>,
|
||||
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||
/// use of its value: presence dates the record. Its meaning was inverted
|
||||
/// and it never worked, so it is read for the marker and discarded.
|
||||
#[serde(default)]
|
||||
enable_session_recap: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||
let pre_widening = stored.enable_session_recap.is_some();
|
||||
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||
// so it means "not set at this level" and must inherit. A `true` was a
|
||||
// real choice either way.
|
||||
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||
ClaudeCodeSettings {
|
||||
tui_mode: stored.tui_mode,
|
||||
effort: stored.effort,
|
||||
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||
focus_mode: read(stored.focus_mode),
|
||||
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||
session_recap_disabled: read(stored.session_recap_disabled),
|
||||
env_scrub: read(stored.env_scrub),
|
||||
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -148,13 +352,14 @@ pub struct Project {
|
||||
/// 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, Tailscale) installs and runs but its connection attempt hangs
|
||||
/// until it times out, because it cannot create the tunnel interface or
|
||||
/// touch the routing table.
|
||||
/// 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 is a meaningful step
|
||||
/// out of the default sandbox. Unlike `auth_bridge_enabled` this *is*
|
||||
/// 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.
|
||||
@@ -217,6 +422,61 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||
/// of reporting a clean removal that was not one.
|
||||
///
|
||||
/// The project record is dropped from `projects.json` regardless — see the
|
||||
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||
/// anything named here is also written to a pending-cleanup record that
|
||||
/// startup housekeeping retries, so it stays reachable after the project it
|
||||
/// belonged to no longer exists.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectRemovalReport {
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||
/// not the container id, since the id can be stale or absent and the
|
||||
/// name is what a later retry can still resolve.
|
||||
pub container: Option<String>,
|
||||
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||
pub image: Option<String>,
|
||||
/// Named volumes (home, claude config) that could not be removed.
|
||||
pub volumes: Vec<String>,
|
||||
/// True once the leftovers above were durably recorded for automatic
|
||||
/// retry on the next launch. False means the pending-cleanup record
|
||||
/// itself could not be written — nothing will retry these, and the UI
|
||||
/// must say so rather than promising a retry that will not happen.
|
||||
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||
pub retry_scheduled: bool,
|
||||
}
|
||||
|
||||
impl ProjectRemovalReport {
|
||||
/// True when nothing was left behind.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||
/// stands after restarting, and anything Reset could not clear.
|
||||
///
|
||||
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||
/// there is no pending-cleanup record for either: the project id survives
|
||||
/// Reset, so a later Reset attempt can retry them itself.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectResetOutcome {
|
||||
pub project: Project,
|
||||
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||
/// more serious of the two leftovers here: the new container is created
|
||||
/// from this image whenever it exists, so a surviving image means Reset
|
||||
/// silently rebuilt the exact system layer it was asked to discard.
|
||||
pub leftover_image: Option<String>,
|
||||
/// Volumes that survived Reset and were mounted into the new container
|
||||
/// unchanged.
|
||||
pub leftover_volumes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
@@ -440,3 +700,189 @@ impl Project {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||
assert!(ProjectRemovalReport::default().is_clean());
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.container = Some("abc123".to_string());
|
||||
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.volumes.push("triple-c-home-x".to_string());
|
||||
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||
}
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||
}
|
||||
for bad in [
|
||||
// bash's wire format for an exported shell function: the value is
|
||||
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||
"BASH_FUNC_stat%%",
|
||||
"BASH_FUNC_ls()",
|
||||
"MY VAR",
|
||||
"2FAST",
|
||||
"WITH-DASH",
|
||||
"WITH.DOT",
|
||||
"",
|
||||
" ",
|
||||
"$(id)",
|
||||
"A=B",
|
||||
] {
|
||||
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||
}
|
||||
}
|
||||
|
||||
fn env(key: &str, value: &str) -> EnvVar {
|
||||
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||
// Introducing it through the Config tab is the escalation.
|
||||
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||
// Already stored: it is handed to every container that starts whether
|
||||
// or not an unrelated save is allowed through, and refusing the save
|
||||
// would make every toggle on the Config tab fail.
|
||||
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||
// Editing its value is a new entry, and refused again.
|
||||
assert!(
|
||||
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||
);
|
||||
// Fixing the name is what the message asks for, and it saves.
|
||||
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||
// Dropping it entirely is always fine.
|
||||
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||
// so this is the button, not an attempt at anything.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||
// Typing the value before the name is an ordinary way to fill it in,
|
||||
// and an entry with no name reaches no container either way.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||
let stored = [env("BAD NAME", "v")];
|
||||
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||
// A second copy is a new entry, and held to the rule.
|
||||
assert!(
|
||||
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code settings written before the fields were widened ───────
|
||||
|
||||
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||
/// booleans were plain `bool`s that always serialised, so every project
|
||||
/// that ever opened the editor carries `false` for the ones it never
|
||||
/// touched.
|
||||
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||
"container_id": null,
|
||||
"status": "stopped",
|
||||
"backend": "anthropic",
|
||||
"bedrock_config": null,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"claude_code_settings": {
|
||||
"tui_mode": "fullscreen",
|
||||
"effort": null,
|
||||
"auto_scroll_disabled": false,
|
||||
"focus_mode": false,
|
||||
"show_thinking_summaries": false,
|
||||
"enable_session_recap": false,
|
||||
"env_scrub": false,
|
||||
"prompt_caching_1h": false
|
||||
},
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
let stored = project.claude_code_settings.expect("settings should parse");
|
||||
|
||||
// Read verbatim these would be `Some(false)`, which under
|
||||
// `docker::container::merge_claude_code_settings` beats the global.
|
||||
assert_eq!(stored.env_scrub, None);
|
||||
assert_eq!(stored.auto_scroll_disabled, None);
|
||||
assert_eq!(stored.focus_mode, None);
|
||||
assert_eq!(stored.show_thinking_summaries, None);
|
||||
assert_eq!(stored.prompt_caching_1h, None);
|
||||
assert_eq!(stored.session_recap_disabled, None);
|
||||
// A value the user did choose is untouched.
|
||||
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||
|
||||
// The merge rule itself, spelled the way
|
||||
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||
// and it has to go on winning, because the user never turned this off.
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(
|
||||
stored.env_scrub.or(global.env_scrub),
|
||||
Some(true),
|
||||
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||
// Same record without the pre-widening key: this `false` is the
|
||||
// deliberate off the widening exists to make expressible.
|
||||
let json = r#"{ "env_scrub": false }"#;
|
||||
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(chosen.env_scrub, Some(false));
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||
// A downgrade parses these fields as plain `bool` with
|
||||
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||
// with one null empties the whole list and the next save persists that.
|
||||
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
assert!(!json.contains("null"));
|
||||
|
||||
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||
let json = serde_json::to_string(&partial).unwrap();
|
||||
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||
// And it reads back as what it is.
|
||||
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(round_tripped, partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Per-project mutual exclusion for everything that rewrites a project's
|
||||
//! container or its snapshot image.
|
||||
//!
|
||||
//! ## Why polling was not enough
|
||||
//!
|
||||
//! Until this module existed the app had exactly one mutual-exclusion
|
||||
//! primitive — the `ACTIVE_MIGRATIONS` set behind
|
||||
//! `migration_commands::is_migrating` — and it was **one-way**. A migration
|
||||
//! took a guard for its whole run; everything else merely *asked once, at
|
||||
//! entry*, whether a migration was in flight and then proceeded with no claim
|
||||
//! of its own. Two non-migration operations on the same project could not see
|
||||
//! each other at all, and a migration could start underneath one that was
|
||||
//! already halfway through.
|
||||
//!
|
||||
//! That is not a theoretical gap. Compaction resolves
|
||||
//! `triple-c-snapshot-{id}:latest` when its build starts and commits back over
|
||||
//! that same tag minutes later, and the Settings panel is a sidebar rather than
|
||||
//! a modal — so Project Home stays live with Start, Stop, Reset and Migrate all
|
||||
//! clickable while a compaction runs. Three interleavings were reproduced:
|
||||
//!
|
||||
//! * Compaction commits `flat(A)` over `:latest` after a migration has already
|
||||
//! moved that tag to a new lineage. The migration is silently reverted, the
|
||||
//! config replay lands twice, and the migration record says
|
||||
//! `awaiting-confirmation` against a base the tag no longer points at.
|
||||
//! * Compaction resolves A, the user starts the project and works for an hour,
|
||||
//! a recreate commits D over `:latest`, and the compaction then overwrites it
|
||||
//! with `flat(A)` — orphaning an hour of system-layer work while reporting
|
||||
//! success and a byte saving.
|
||||
//! * Compaction resurrects the system layer a Reset had just destroyed.
|
||||
//!
|
||||
//! Every one of those is "two writers of `:latest`, neither holding anything".
|
||||
//! So this registry replaces the polling with an actual claim: an operation
|
||||
//! **acquires** a [`ProjectGuard`] and holds it for its whole run, and a second
|
||||
//! operation on the same project is refused with a message naming the holder.
|
||||
//!
|
||||
//! ## What this does NOT protect against, stated plainly
|
||||
//!
|
||||
//! **This is in-process state.** Two copies of the app pointed at the same
|
||||
//! Docker daemon share nothing here: instance A's compaction and instance B's
|
||||
//! migration will both acquire happily and then race exactly as before.
|
||||
//! `reap_probe_containers` and the `triple-c-compact-*` / `triple-c-scrub-*`
|
||||
//! sweeps are worse than that — they are daemon-wide force-removals driven by
|
||||
//! a name or a label, so instance B can destroy a container instance A is
|
||||
//! mid-commit against.
|
||||
//!
|
||||
//! A daemon-visible lock was considered and rejected for now, and the reasoning
|
||||
//! is recorded here so it is not re-derived from scratch:
|
||||
//!
|
||||
//! * A **lock container** would work — container names are unique daemon-wide
|
||||
//! and `create` fails atomically on a name conflict — but a container has to
|
||||
//! be created *from an image*, and that pins the image. A lock on
|
||||
//! `triple-c-snapshot-{id}` would block the very `rmi`/sweep paths it guards,
|
||||
//! and a leaked lock container would pin multiple gigabytes forever.
|
||||
//! * A **named volume** is not usable: `create_volume` on an existing name
|
||||
//! returns the existing volume rather than failing, so it cannot be a
|
||||
//! test-and-set.
|
||||
//! * A **label on the snapshot image** is not atomic — read/modify/commit has
|
||||
//! the same race it would be trying to close.
|
||||
//!
|
||||
//! So the cross-process case is **documented, not solved**. What this module
|
||||
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
|
||||
//! reapers skip work while this process is mid-operation, and the reapers
|
||||
//! themselves gained age gates so a young container belonging to somebody else
|
||||
//! is left alone (see `docker::migration::reap_probe_containers`).
|
||||
//!
|
||||
//! ## Refuse, do not queue
|
||||
//!
|
||||
//! [`try_acquire`] never waits. Every caller is a user-initiated action behind
|
||||
//! a button, and a button that blocks for the four minutes a compaction takes
|
||||
//! is worse than one that says what is running. The refusal string is written
|
||||
//! for the user and names the holder.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// The operations that claim a project.
|
||||
///
|
||||
/// One variant per *class of writer*, not per command: `Recreate` covers Start
|
||||
/// as well, because Start's create-and-commit path is the same writer of
|
||||
/// `triple-c-snapshot-{id}:latest` that a recreate is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProjectOp {
|
||||
/// `migrate_project_to_base`, `resume_migration`, `rollback_migration`,
|
||||
/// `confirm_migration`.
|
||||
Migration,
|
||||
/// `disk::compact_snapshot` — the long one, and the reason this exists.
|
||||
///
|
||||
/// Not constructed on this branch: the Disk panel and its compaction were
|
||||
/// held back for separate hardening and live on `hold/disk-and-dragout`.
|
||||
/// The variant stays because this registry is the thing that made those
|
||||
/// operations safe to re-land, and a re-land that had to re-derive the
|
||||
/// claim classes would be re-deriving the bug.
|
||||
#[allow(dead_code)]
|
||||
Compaction,
|
||||
/// Start / stop / recreate. Anything in `start_project_container`'s path.
|
||||
Recreate,
|
||||
/// `rebuild_project_container` — deletes both volumes and the snapshot.
|
||||
Reset,
|
||||
/// `disk::destroy` — a volume, a snapshot image, or a rollback pin.
|
||||
Destroy,
|
||||
/// `disk::clear_caches` — an exec into the live container. It does not
|
||||
/// write `:latest`, but it must not run while the container is being
|
||||
/// removed out from under it.
|
||||
///
|
||||
/// Not constructed on this branch, for the same reason as
|
||||
/// [`ProjectOp::Compaction`].
|
||||
#[allow(dead_code)]
|
||||
CacheClear,
|
||||
/// `container::scrub_secrets_from_snapshots` — the third writer of
|
||||
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
|
||||
/// creates a scratch container from the snapshot and commits back over the
|
||||
/// same tag, so it is the same read-modify-write shape as a compaction and
|
||||
/// loses the same race: any `:latest` move landing between its create and
|
||||
/// its commit is overwritten by an image derived from the pre-read state.
|
||||
SecretScrub,
|
||||
}
|
||||
|
||||
impl ProjectOp {
|
||||
/// What is happening, phrased for the message a user reads.
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "A container base update is running for this project",
|
||||
ProjectOp::Compaction => "This project's snapshot is being compacted",
|
||||
ProjectOp::Recreate => "This project's container is being started or recreated",
|
||||
ProjectOp::Reset => "This project is being reset",
|
||||
ProjectOp::Destroy => "Something of this project's is being deleted",
|
||||
ProjectOp::CacheClear => "This project's caches are being cleared",
|
||||
ProjectOp::SecretScrub => "A revoked credential is being removed from this project's snapshot",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the *refused* caller was trying to do, for the tail of the message.
|
||||
fn blocked_action(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "starting a base update",
|
||||
ProjectOp::Compaction => "compacting its snapshot",
|
||||
ProjectOp::Recreate => "starting or recreating its container",
|
||||
ProjectOp::Reset => "resetting it",
|
||||
ProjectOp::Destroy => "deleting anything of its",
|
||||
ProjectOp::CacheClear => "clearing its caches",
|
||||
ProjectOp::SecretScrub => "removing a credential from its snapshot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project id → the operation currently holding it.
|
||||
///
|
||||
/// A `std::sync::Mutex` rather than a `tokio` one on purpose: it is only ever
|
||||
/// held for the length of a `HashMap` insert or remove, never across an await,
|
||||
/// and [`is_held_by`] has to be callable from the synchronous helpers in
|
||||
/// `disk.rs` that already ask this question.
|
||||
static HOLDERS: OnceLock<Mutex<HashMap<String, ProjectOp>>> = OnceLock::new();
|
||||
|
||||
fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
|
||||
HOLDERS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A claim on one project, released on drop.
|
||||
///
|
||||
/// RAII rather than an explicit release for the reason [`ProjectOp::Migration`]'s
|
||||
/// predecessor already learned: a plain release statement is skipped by an
|
||||
/// early `?`, by a panic, and by the future simply being dropped. A guard is
|
||||
/// not.
|
||||
/// Dropping this releases the claim, so a caller that discards it has taken no
|
||||
/// lock at all — `let _ = try_acquire(...)` drops immediately and reads as
|
||||
/// success. `#[must_use]` makes that a compile warning rather than a race.
|
||||
#[must_use = "the claim is released as soon as this guard is dropped; bind it for the whole operation"]
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGuard {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ProjectGuard {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` on a poisoned lock: a panic while some other thread held
|
||||
// this map for the duration of one insert cannot have left it
|
||||
// inconsistent, and refusing to release afterwards would strand the
|
||||
// project as permanently busy.
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim a project for `op`, or say who has it.
|
||||
///
|
||||
/// The error is user-facing copy, not a debug string — it goes straight back
|
||||
/// over IPC to a toast.
|
||||
pub fn try_acquire(project_id: &str, op: ProjectOp) -> Result<ProjectGuard, String> {
|
||||
let mut map = holders().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(holder) = map.get(project_id).copied() {
|
||||
return Err(format!(
|
||||
"{}. Wait for it to finish before {}.",
|
||||
holder.describe(),
|
||||
op.blocked_action()
|
||||
));
|
||||
}
|
||||
map.insert(project_id.to_string(), op);
|
||||
Ok(ProjectGuard {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which operation holds this project, if any.
|
||||
pub fn held(project_id: &str) -> Option<ProjectOp> {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(project_id)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Whether this project is held by exactly `op`.
|
||||
///
|
||||
/// `migration_commands::is_migrating` is this, specialised — which is the whole
|
||||
/// point of folding `ACTIVE_MIGRATIONS` into this registry: there is now one
|
||||
/// answer to "is something happening to this project", not two that can
|
||||
/// disagree.
|
||||
pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
|
||||
held(project_id) == Some(op)
|
||||
}
|
||||
|
||||
/// Whether any project **other than** `exclude_project_id` is currently held by
|
||||
/// `op`. Pass an empty id to ask about every project.
|
||||
///
|
||||
/// Used by the daemon-wide reapers, which cannot tell which project a
|
||||
/// `triple-c-compact-*` container belongs to — the name carries a random uuid,
|
||||
/// not a project id — so "is this process compacting anything right now" is the
|
||||
/// only in-process question they can ask before force-removing one. The
|
||||
/// exclusion is for the reaper that runs *inside* a compaction, which is
|
||||
/// already holding a claim of its own and would otherwise see it and skip.
|
||||
///
|
||||
/// No production caller on this branch: the compaction reaper it was written
|
||||
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
|
||||
/// (and still tested) because it is the only bound this module offers on the
|
||||
/// cross-process case documented above.
|
||||
#[allow(dead_code)]
|
||||
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.any(|(project_id, held)| *held == op && project_id != exclude_project_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are namespaced per test: the registry is process-global, and
|
||||
/// `cargo test` runs these on several threads at once.
|
||||
fn id(name: &str) -> String {
|
||||
format!("project-lock-test-{}", name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_acquire_on_the_same_project_is_refused() {
|
||||
let p = id("second-acquire");
|
||||
let first = try_acquire(&p, ProjectOp::Compaction).expect("first claim");
|
||||
let second = try_acquire(&p, ProjectOp::Recreate);
|
||||
let err = second.expect_err("a second claim must be refused, not queued");
|
||||
// The refusal has to name the holder — "busy" alone leaves the user
|
||||
// with nothing to wait for.
|
||||
assert!(err.contains("snapshot is being compacted"), "{}", err);
|
||||
assert!(err.contains("starting or recreating"), "{}", err);
|
||||
drop(first);
|
||||
// And it has to be retakeable the moment the holder goes away. Bound
|
||||
// rather than discarded: `#[must_use]` is what stops a real caller
|
||||
// writing `try_acquire(...)` and believing it holds something.
|
||||
let retaken = try_acquire(&p, ProjectOp::Recreate).expect("released on drop");
|
||||
drop(retaken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_an_early_return() {
|
||||
let p = id("early-return");
|
||||
fn bails(project_id: &str) -> Result<(), String> {
|
||||
let _guard = try_acquire(project_id, ProjectOp::Reset)?;
|
||||
Err("something failed".to_string())
|
||||
}
|
||||
assert!(bails(&p).is_err());
|
||||
assert_eq!(held(&p), None, "an early `?` must not strand the claim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_a_panic() {
|
||||
let p = id("panic");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let _guard = try_acquire(&id("panic"), ProjectOp::Migration).unwrap();
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(held(&p), None, "a panic must not strand the claim either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_projects_do_not_block_each_other() {
|
||||
let a = id("independent-a");
|
||||
let b = id("independent-b");
|
||||
let _one = try_acquire(&a, ProjectOp::Compaction).expect("a");
|
||||
let _two = try_acquire(&b, ProjectOp::Compaction).expect("b");
|
||||
assert!(is_held_by(&a, ProjectOp::Compaction));
|
||||
assert!(is_held_by(&b, ProjectOp::Compaction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_held_by_distinguishes_the_operation() {
|
||||
let p = id("which-op");
|
||||
let _guard = try_acquire(&p, ProjectOp::Compaction).unwrap();
|
||||
assert!(is_held_by(&p, ProjectOp::Compaction));
|
||||
assert!(
|
||||
!is_held_by(&p, ProjectOp::Migration),
|
||||
"a compaction is not a migration — `is_migrating` is built on this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_held_sees_across_projects() {
|
||||
let p = id("any-held");
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, ""));
|
||||
let _guard = try_acquire(&p, ProjectOp::Destroy).unwrap();
|
||||
assert!(any_held_excluding(ProjectOp::Destroy, ""));
|
||||
// …and a holder can ask the question without its own claim answering
|
||||
// it, which is what lets a compaction sweep leftovers before it starts.
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, &p));
|
||||
}
|
||||
|
||||
/// Concurrency, not just sequencing: N threads racing for one project must
|
||||
/// produce exactly one winner.
|
||||
#[test]
|
||||
fn exactly_one_of_many_racing_threads_wins() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let p = id("race");
|
||||
let start = Arc::new(std::sync::Barrier::new(8));
|
||||
// The second barrier is what makes this deterministic rather than
|
||||
// merely likely: no winner releases until every thread has had its
|
||||
// turn, so "only one got in" cannot be an artefact of a loser arriving
|
||||
// after the winner already left.
|
||||
let attempted = Arc::new(std::sync::Barrier::new(8));
|
||||
let won = Arc::new(AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let start = Arc::clone(&start);
|
||||
let attempted = Arc::clone(&attempted);
|
||||
let won = Arc::clone(&won);
|
||||
let p = p.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
start.wait();
|
||||
let claim = try_acquire(&p, ProjectOp::Compaction);
|
||||
if claim.is_ok() {
|
||||
won.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
attempted.wait();
|
||||
drop(claim);
|
||||
}));
|
||||
}
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
won.load(Ordering::SeqCst),
|
||||
1,
|
||||
"eight threads raced for one project and more than one got in"
|
||||
);
|
||||
assert_eq!(held(&p), None);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,29 @@ fn sanitize(project_id: &str) -> String {
|
||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||
/// blocking every future migration on a corrupt record.
|
||||
///
|
||||
/// **A corrupt record is copied aside and left in place.** An earlier version
|
||||
/// *renamed* it to `.bak`, on the reasoning that a file nothing can parse
|
||||
/// should stop making the project look busy. That destroyed the one signal
|
||||
/// [`has_record`] exists to carry. The chain, in order:
|
||||
///
|
||||
/// 1. The rename makes the file vanish, so `has_record` — pure filesystem
|
||||
/// presence — flips to false.
|
||||
/// 2. `reconcile_migration` calls this, gets `Ok(None)`, and returns. An
|
||||
/// in-flight or interrupted migration becomes invisible: no resume offer, no
|
||||
/// rollback offer, and the phase is never normalised.
|
||||
/// 3. Both pin reapers use `has_record` as their conservative guard, so the
|
||||
/// project's `:pre-migration-*` tag — the only copy of its pre-migration
|
||||
/// system layer — is now "ownerless" to both of them, and the startup sweep
|
||||
/// turns the untag into a deletion.
|
||||
///
|
||||
/// A record that cannot be parsed is exactly the case where the *most*
|
||||
/// conservative answer is wanted, not the least. So the bytes are copied to a
|
||||
/// **uniquely named** backup (a fixed `.bak` meant a second corruption silently
|
||||
/// overwrote the first, and nothing ever read either back) and the original
|
||||
/// stays where it is. The pin it describes then ages out through the ownerless
|
||||
/// tombstone in `docker::migration::reap_stale_migration_pins` rather than
|
||||
/// being reaped on the next app start.
|
||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
let path = state_path(project_id)?;
|
||||
if !path.exists() {
|
||||
@@ -59,27 +82,326 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
match serde_json::from_str::<MigrationState>(&data) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => {
|
||||
// Three outcomes, and they must not be conflated: a copy was made,
|
||||
// a copy was deliberately not made, or a copy failed. The previous
|
||||
// version folded "already kept enough" into `Ok(())` and then told
|
||||
// the user "a copy was kept at <path>" — naming a file that was
|
||||
// never created. A message that invents a backup is worse than no
|
||||
// message, because it is what someone reads before going to look
|
||||
// for their data.
|
||||
let backup = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
let kept = if backup.exists() {
|
||||
Kept::AlreadyThere
|
||||
} else if corrupt_backups_full(&path) {
|
||||
Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)
|
||||
} else {
|
||||
match fs::copy(&path, &backup) {
|
||||
Ok(_) => Kept::Copied,
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
};
|
||||
log::error!(
|
||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||
"Failed to parse migration state for project {}: {} — treating as absent, but \
|
||||
the record is left in place so `has_record` still protects its rollback pin{}",
|
||||
project_id,
|
||||
e
|
||||
e,
|
||||
match kept {
|
||||
Kept::Copied | Kept::AlreadyThere =>
|
||||
format!(" (a copy is at {})", backup.display()),
|
||||
// The earliest copies are the ones worth having, so the cap
|
||||
// keeps those and drops this one. Say so, rather than
|
||||
// implying a file exists.
|
||||
Kept::EnoughAlready(n) => format!(
|
||||
" (no copy kept — {} earlier copies of this record are already saved \
|
||||
alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(ref e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write a project's migration state.
|
||||
/// What [`load`] did about a copy of an unparseable record, so the log line can
|
||||
/// tell the truth about whether a file exists.
|
||||
enum Kept {
|
||||
Copied,
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere,
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Where a copy of an unparseable record is kept.
|
||||
///
|
||||
/// Timestamped rather than a fixed `.bak`: a second corruption used to
|
||||
/// overwrite the first, so the one case where the user's bytes matter most was
|
||||
/// the case where they were most likely to be gone.
|
||||
fn corrupt_backup_path(path: &std::path::Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt record are kept.
|
||||
///
|
||||
/// Timestamping fixed the "second corruption overwrote the first" bug and
|
||||
/// introduced its opposite: [`load`] runs on every reconcile, every survey and
|
||||
/// every reaper pass, so a record that is *persistently* unparseable — the
|
||||
/// normal case, since nothing repairs it — mints a new copy every time the
|
||||
/// clock's second changes. Nothing ever reads them back and nothing ever
|
||||
/// removed them.
|
||||
///
|
||||
/// Four is enough for the only use there is: a human looking at what the file
|
||||
/// held. See [`corrupt_backups_full`] for why the cap is applied before the
|
||||
/// copy rather than by pruning after it.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this record already exist.
|
||||
///
|
||||
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||
/// implemented by writing a file and deleting it again on every pass — and so
|
||||
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||
/// whatever produced the corruption.
|
||||
///
|
||||
/// A directory that cannot be listed answers "not full": failing open here
|
||||
/// costs at most one extra file, and failing closed would drop the very first
|
||||
/// copy of a record nothing else has kept.
|
||||
fn corrupt_backups_full(path: &std::path::Path) -> bool {
|
||||
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||
return false;
|
||||
};
|
||||
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||
// this can never match another project's copies or an unrelated `.bak`.
|
||||
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||
})
|
||||
.count()
|
||||
>= MAX_CORRUPT_BACKUPS
|
||||
}
|
||||
|
||||
/// Whether a project has a migration record on disk *at all*, without parsing
|
||||
/// it.
|
||||
///
|
||||
/// The pin reaper needs "is this project's rollback image still somebody's only
|
||||
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
|
||||
/// question on its own — it deliberately reports a corrupt record as absent —
|
||||
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
|
||||
/// what keeps the two answers from disagreeing forever.
|
||||
pub fn has_record(project_id: &str) -> Result<bool, String> {
|
||||
Ok(state_path(project_id)?.exists())
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write a project's migration state.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it, and the missing half is the
|
||||
/// half this record exists for. `fs::write` returns once the bytes are in the
|
||||
/// page cache; a rename over them is atomic *with respect to other readers*,
|
||||
/// not with respect to power loss. Losing power in that window leaves the
|
||||
/// rename applied and the data not yet written — i.e. a 0-byte or truncated
|
||||
/// `{id}.json` — which is precisely the corrupt-record case above, produced by
|
||||
/// the code whose job is to make that case impossible.
|
||||
///
|
||||
/// So: fsync the file before the rename, and fsync the *directory* after it,
|
||||
/// because the rename itself is directory metadata and is not durable until the
|
||||
/// directory is synced. A sync that fails is reported rather than swallowed —
|
||||
/// this is the crash record, and "probably written" is not a state it may be
|
||||
/// in.
|
||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
let data = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush migration state to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||
sync_dir(&path);
|
||||
// A project with a record is not ownerless, whatever a reaper concluded
|
||||
// before this write — so the grace clock is thrown away rather than left to
|
||||
// expire against a pin that now has an owner again.
|
||||
clear_ownerless_for_project(project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power loss.
|
||||
///
|
||||
/// Best effort *only* on the platforms where it is meaningless: Windows has no
|
||||
/// directory handle to sync and returns an error for the attempt, so a failure
|
||||
/// is logged rather than propagated. The file's own `sync_all` above is the
|
||||
/// part that carries the data, and it is not best effort.
|
||||
fn sync_dir(path: &std::path::Path) {
|
||||
let Some(dir) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the migrations directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ownerless-pin tombstones
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker recording **when a rollback pin was first seen with no record behind
|
||||
/// it**.
|
||||
///
|
||||
/// ## Why the grace period cannot be measured from the tag
|
||||
///
|
||||
/// `docker::migration::pin_is_reapable` used to date a pin from the timestamp
|
||||
/// encoded in `pre-migration-<YYYYmmdd-HHMMSS>` — i.e. from when the migration
|
||||
/// *started*. That is the wrong epoch by a whole feature. A migration is
|
||||
/// allowed to sit at `awaiting-confirmation` indefinitely; `keep_rollback`
|
||||
/// exists precisely so a user can run on the new base for a month before
|
||||
/// deciding. If that project's record is then lost — a corrupt file, a deleted
|
||||
/// state file, a half-restored data directory — the pin is fourteen days old on
|
||||
/// the very first check, so it is untagged on the next app start and the
|
||||
/// startup sweep deletes the image two lines later. The fourteen-day grace
|
||||
/// period the constant promises is zero in the only situation it was written
|
||||
/// for.
|
||||
///
|
||||
/// The clock has to start when the *claim* was lost, and nothing on the daemon
|
||||
/// records that moment. So it is written down here, the first time a reaper
|
||||
/// notices, and the age is measured from the marker.
|
||||
///
|
||||
/// One file per `(project_id, tag)` in the migrations directory, holding an
|
||||
/// RFC3339 instant. Tiny, and losing one costs a fresh fourteen days rather
|
||||
/// than a deletion — the failure direction that keeps somebody's only rollback
|
||||
/// copy.
|
||||
fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!(
|
||||
"{}.{}.ownerless",
|
||||
sanitize(project_id),
|
||||
sanitize(tag)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read the first-observed instant for a pin, creating the marker if this is
|
||||
/// the first sighting. Returns `None` when the clock has not started yet.
|
||||
///
|
||||
/// **Clock skew is handled here rather than at the comparison.** A host clock
|
||||
/// that was running fast when the marker was written leaves a timestamp in the
|
||||
/// future; measured naively that is a negative age, which a `num_days() >= 14`
|
||||
/// test reads as "never reapable" — a pin that can never be collected, forever.
|
||||
/// A marker dated after `now` is therefore rewritten to `now`, restarting the
|
||||
/// grace period. The other direction — a clock jumping forward — cannot shorten
|
||||
/// the period below what has actually elapsed on the *marker's* terms, because
|
||||
/// there is nothing to compare against but wall time; what it cannot do any
|
||||
/// more is make every pin instantly reapable, which dating from the tag did.
|
||||
///
|
||||
/// ## Why the write re-checks `has_record`
|
||||
///
|
||||
/// Both reapers ask [`has_record`] and only call this when the answer is no,
|
||||
/// which leaves a window: a [`save`] landing between the two runs its
|
||||
/// `clear_ownerless_for_project` against a marker that does not exist yet, and
|
||||
/// this then plants one — dated *now* — behind a perfectly valid record. The
|
||||
/// marker is invisible while the record stands, so nothing notices. It only
|
||||
/// matters later, if that record is legitimately lost: the pin is then already
|
||||
/// fourteen days ownerless on its very first check and is reaped with **zero**
|
||||
/// grace, which is the exact failure the tombstone exists to prevent.
|
||||
///
|
||||
/// So the write is followed by a second `has_record`, and a marker that turns
|
||||
/// out to sit behind a record is removed again. The two orderings that remain
|
||||
/// are both safe: a `save` completing *after* this re-check clears the marker
|
||||
/// itself, and one completing before it is what the re-check sees.
|
||||
pub fn note_ownerless_since(
|
||||
project_id: &str,
|
||||
tag: &str,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let path = ownerless_marker_path(project_id, tag).ok()?;
|
||||
let existing = fs::read_to_string(&path).ok().and_then(|raw| {
|
||||
chrono::DateTime::parse_from_rfc3339(raw.trim())
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&chrono::Utc))
|
||||
});
|
||||
match existing {
|
||||
Some(seen) if seen <= *now => Some(seen),
|
||||
// Absent, unparseable, or dated in the future: (re)start the clock.
|
||||
_ => {
|
||||
if let Err(e) = fs::write(&path, now.to_rfc3339()) {
|
||||
log::warn!(
|
||||
"Could not record that rollback pin {}:{} is ownerless: {} — its grace \
|
||||
period restarts on the next check",
|
||||
project_id,
|
||||
tag,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A record that appeared while this was being written owns the pin,
|
||||
// and a tombstone behind an owned pin is a fourteen-day head start
|
||||
// on reaping it the moment that record is next lost.
|
||||
if has_record(project_id).unwrap_or(false) {
|
||||
log::debug!(
|
||||
"A migration record for {} appeared while marking {} ownerless; \
|
||||
the marker was dropped again",
|
||||
project_id,
|
||||
tag
|
||||
);
|
||||
clear_ownerless(project_id, tag);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a pin's ownerless marker. Missing is success.
|
||||
///
|
||||
/// Called when the pin is untagged, and when a record reappears for the
|
||||
/// project — a re-migrated project must not inherit the previous run's clock.
|
||||
pub fn clear_ownerless(project_id: &str, tag: &str) {
|
||||
let Ok(path) = ownerless_marker_path(project_id, tag) else {
|
||||
return;
|
||||
};
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("Could not remove {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every ownerless marker belonging to one project.
|
||||
///
|
||||
/// A project that has a record again is by definition not ownerless, whatever
|
||||
/// a reaper concluded before.
|
||||
pub fn clear_ownerless_for_project(project_id: &str) {
|
||||
let Ok(dir) = migrations_dir() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.", sanitize(project_id));
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with(&prefix) && name.ends_with(".ownerless") {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a project's migration state file. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
@@ -104,6 +426,39 @@ pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_of_one_record_are_capped() {
|
||||
// `load` runs on every reconcile, every survey and every reaper pass,
|
||||
// and nothing repairs an unparseable record — so a persistently corrupt
|
||||
// one minted a new timestamped copy every time the clock's second
|
||||
// changed, and nothing ever removed them.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-corrupt-cap-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp dir");
|
||||
let record = dir.join("some-project.json");
|
||||
|
||||
assert!(!corrupt_backups_full(&record), "an empty directory is not full");
|
||||
for n in 0..MAX_CORRUPT_BACKUPS {
|
||||
fs::write(
|
||||
dir.join(format!("some-project.json.corrupt-2026010{}-000000.bak", n)),
|
||||
"x",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&record));
|
||||
|
||||
// Another project's copies, and an unrelated `.bak`, are not this
|
||||
// record's — the prefix is the whole point of the naming.
|
||||
let other = dir.join("other-project.json");
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
fs::write(dir.join("some-project.json.bak"), "x").unwrap();
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
@@ -115,4 +470,43 @@ mod tests {
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
/// The log line must not name a backup that was never written.
|
||||
///
|
||||
/// `load` runs on every reconcile, every survey and every reaper pass, so a
|
||||
/// persistently corrupt record hits the `MAX_CORRUPT_BACKUPS` cap within
|
||||
/// seconds. The previous code folded "already kept enough" into `Ok(())`
|
||||
/// and then reported " (a copy was kept at <path>)" — pointing at a file
|
||||
/// that does not exist. That is the message someone reads immediately
|
||||
/// before going to look for their data.
|
||||
#[test]
|
||||
fn the_corrupt_record_message_only_claims_a_copy_that_exists() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"tc-mstore-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("p.json");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
// Fill the cap with copies that really are on disk.
|
||||
for i in 0..MAX_CORRUPT_BACKUPS {
|
||||
let b = path.with_extension(format!("json.corrupt-2026010{}-000000.bak", i));
|
||||
std::fs::write(&b, b"{ not json").unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&path), "precondition: the cap is reached");
|
||||
|
||||
// With the cap reached, no new copy may be created — and that is the
|
||||
// state in which the old message lied.
|
||||
let before: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
let fresh = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
assert!(
|
||||
!fresh.exists(),
|
||||
"the cap is reached, so this timestamped copy must not be written"
|
||||
);
|
||||
let after: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
assert_eq!(before.len(), after.len(), "nothing new appeared on disk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod migration_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||
//!
|
||||
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||
//! in the app can name the leftover container, image or volume again by any
|
||||
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||
//! written *before* the project record is dropped. Startup housekeeping
|
||||
//! retries every record on the next launch (see
|
||||
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||
//! the ones that fully succeed.
|
||||
//!
|
||||
//! **This record is written in the same instant its record in `projects.json`
|
||||
//! is destroyed, and it is the only remaining handle on the leftover
|
||||
//! resource** — which is a stronger claim on durability than an ordinary
|
||||
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||
//! to other readers, not to power loss. A crash in that window leaves the
|
||||
//! rename applied and the data half-written, which [`list`] then treats as
|
||||
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||
//! directory-sync shape `migration_store` does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCleanup {
|
||||
pub project_id: String,
|
||||
/// Kept only so a log line or a future UI can name the project without a
|
||||
/// second lookup — the project record itself is already gone by the time
|
||||
/// this is read back.
|
||||
pub project_name: String,
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||
/// container id Docker handed out — Docker's remove-container API
|
||||
/// accepts either, and the name is the one identifier guaranteed to still
|
||||
/// resolve to the same container by the time a retry runs.
|
||||
pub container_id: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
impl PendingCleanup {
|
||||
/// True once nothing named here still needs to be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||
fn dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("pending-cleanup");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||
/// `storage::migration_store::sanitize`.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) a project's pending-cleanup record.
|
||||
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||
save_in(&dir()?, record)
|
||||
}
|
||||
|
||||
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(&dir()?, project_id)
|
||||
}
|
||||
|
||||
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||
/// skipped rather than blocking every other project's retry — the same
|
||||
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||
pub fn list() -> Vec<PendingCleanup> {
|
||||
let Ok(dir) = dir() else { return Vec::new() };
|
||||
list_in(&dir)
|
||||
}
|
||||
|
||||
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||
/// after it — see the module doc comment for why a plain
|
||||
/// write-temp-then-rename is not enough here. Mirrors
|
||||
/// `storage::migration_store::save`/`sync_dir`.
|
||||
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||
let path = path_in(dir, &record.project_id);
|
||||
let data = serde_json::to_string_pretty(record)
|
||||
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let path = path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let data = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) => {
|
||||
// Moved aside rather than left in place: a record nothing
|
||||
// ever repairs would otherwise warn on every single
|
||||
// startup forever, same as an ordinary `.json` file it
|
||||
// would keep looking like one to `list_in` on the next
|
||||
// call too. One aside-copy is enough here — this only
|
||||
// ever holds names to retry removing, not the class of
|
||||
// once-in-a-lifetime crash evidence `migration_store`
|
||||
// keeps multiple timestamped backups of.
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||
log::warn!(
|
||||
"Could not parse pending cleanup record {}: {}{}",
|
||||
path.display(),
|
||||
err,
|
||||
if moved {
|
||||
format!(" — moved aside to {}", corrupt.display())
|
||||
} else {
|
||||
" — leaving it in place".to_string()
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power
|
||||
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||
/// private to that module, so this is a small deliberate duplicate rather
|
||||
/// than a shared dependency between two otherwise-independent stores.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-pending-cleanup-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn record(project_id: &str) -> PendingCleanup {
|
||||
PendingCleanup {
|
||||
project_id: project_id.to_string(),
|
||||
project_name: "Some Project".to_string(),
|
||||
container_id: Some("triple-c-abc".to_string()),
|
||||
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||
volumes: vec!["triple-c-home-abc".to_string()],
|
||||
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||
let mut r = record("p1");
|
||||
assert!(!r.is_empty());
|
||||
|
||||
r.container_id = None;
|
||||
r.image = None;
|
||||
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||
|
||||
r.volumes.clear();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||
/// re-implementation of their bodies — against a temp directory standing
|
||||
/// in for `dir()`.
|
||||
#[test]
|
||||
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let rec = record("proj-1");
|
||||
|
||||
save_in(&dir, &rec).expect("save");
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-1");
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
clear_in(&dir, "proj-1").expect("clear");
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A second `save` for the same project overwrites rather than appending
|
||||
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||
/// record behind it.
|
||||
#[test]
|
||||
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||
let dir = temp_dir("overwrite");
|
||||
let mut rec = record("proj-1");
|
||||
save_in(&dir, &rec).expect("save");
|
||||
|
||||
rec.container_id = None;
|
||||
rec.image = None;
|
||||
save_in(&dir, &rec).expect("save again");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||
assert!(found[0].container_id.is_none());
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse must not poison the rest of the listing.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||
let dir = temp_dir("corrupt");
|
||||
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||
save_in(&dir, &record("proj-2")).expect("save");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-2");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse is moved aside once, rather than left in
|
||||
/// place to be re-warned about — and re-warned about — on every future
|
||||
/// launch forever.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||
let dir = temp_dir("corrupt-aside");
|
||||
let bad = dir.join("bad.json");
|
||||
fs::write(&bad, "{ not json").unwrap();
|
||||
|
||||
list_in(&dir);
|
||||
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||
let corrupt = dir.join("bad.json.corrupt");
|
||||
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||
|
||||
// A second pass must not warn about `bad.json` again — it is gone —
|
||||
// and must not choke on `.json.corrupt` already being there.
|
||||
assert!(list_in(&dir).is_empty());
|
||||
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||
/// leaves behind if a crash lands between the write and the rename — the
|
||||
/// whole point of the temp-then-rename dance is that only the renamed
|
||||
/// file is ever a complete record.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_not_listed() {
|
||||
let dir = temp_dir("tmp-leftover");
|
||||
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing by project id must remove exactly the file that id maps to
|
||||
/// under `sanitize`, and nothing else.
|
||||
#[test]
|
||||
fn clearing_one_project_does_not_touch_another() {
|
||||
let dir = temp_dir("clear-scoped");
|
||||
save_in(&dir, &record("proj-a")).unwrap();
|
||||
save_in(&dir, &record("proj-b")).unwrap();
|
||||
|
||||
clear_in(&dir, "proj-a").unwrap();
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-b");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,65 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::Project;
|
||||
|
||||
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
|
||||
///
|
||||
/// Derived from the file rather than from `dirs::data_dir()` so the marker
|
||||
/// always lands in the directory the store is actually using — and so the
|
||||
/// writer can be tested against a temp directory.
|
||||
fn corrupt_marker_for(file_path: &Path) -> PathBuf {
|
||||
file_path.with_extension("json.corrupt")
|
||||
}
|
||||
|
||||
/// Keep the bytes of an unparseable `projects.json`, and record that it
|
||||
/// happened.
|
||||
///
|
||||
/// **The existing `.bak` is never overwritten.** A second corruption used to
|
||||
/// clobber the first, and the first is the valuable one: it was taken before
|
||||
/// the app rewrote the file with whatever it had in memory, so it is the only
|
||||
/// copy that can still hold the full project list. Later ones are copies of an
|
||||
/// already-degraded file and get a timestamped name.
|
||||
fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
let first = file_path.with_extension("json.bak");
|
||||
let backup = if first.exists() {
|
||||
file_path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
} else {
|
||||
first
|
||||
};
|
||||
if !backup.exists() {
|
||||
if let Err(e) = fs::copy(file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", e);
|
||||
} else {
|
||||
log::error!(
|
||||
"A copy of the unreadable projects.json was kept at {}",
|
||||
backup.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky, and written even though nothing in the app reads it back on this
|
||||
// branch: the Disk panel's `project_store_trust` was the reader and went to
|
||||
// `hold/disk-and-dragout`. The marker stays because it is the only durable
|
||||
// record that a project list was lost — the in-memory symptom does not
|
||||
// survive the next save — and because re-deriving *when* it happened is
|
||||
// impossible after the fact.
|
||||
let marker = corrupt_marker_for(file_path);
|
||||
if marker.exists() {
|
||||
// The *first* corruption is the one that dates the loss.
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
|
||||
log::error!(
|
||||
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
|
||||
tell later that the project list was incomplete",
|
||||
marker.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectsStore {
|
||||
projects: Mutex<Vec<Project>>,
|
||||
file_path: PathBuf,
|
||||
@@ -43,20 +99,14 @@ impl ProjectsStore {
|
||||
Ok(parsed) => (parsed, migrated),
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||
}
|
||||
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||
}
|
||||
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
@@ -203,3 +253,89 @@ impl ProjectsStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-store-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_load_leaves_a_marker_the_next_write_cannot_erase() {
|
||||
// H-3, the whole chain in one test. `ProjectsStore::new()` swallows an
|
||||
// unparseable file into an empty list *without rewriting it*, and the
|
||||
// first `save()` after that — as little as `update_status()` — writes
|
||||
// `[{one project}]` over it. Everything the old guard keyed on ("the
|
||||
// list is empty and the file exists") is gone at that point, while
|
||||
// every *other* project's volumes are still on the daemon claimed by
|
||||
// nobody.
|
||||
let dir = temp_dir("corrupt");
|
||||
let file = dir.join("projects.json");
|
||||
fs::write(&file, "{ this is not a project list").unwrap();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
record_corrupt_load(&file, &now);
|
||||
|
||||
let marker = corrupt_marker_for(&file);
|
||||
assert!(marker.exists(), "the corrupt load must be recorded on disk");
|
||||
assert_eq!(fs::read_to_string(&marker).unwrap(), now.to_rfc3339());
|
||||
assert!(
|
||||
dir.join("projects.json.bak").exists(),
|
||||
"the unreadable bytes must be kept"
|
||||
);
|
||||
|
||||
// The write that used to erase the evidence. The marker is a separate
|
||||
// file, so it does not care.
|
||||
fs::write(&file, r#"[{"id":"the-one-project-started-since"}]"#).unwrap();
|
||||
assert!(marker.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_corruption_keeps_the_first_copy_and_the_first_date() {
|
||||
// The `.bak` used to be a fixed name, so a second corruption clobbered
|
||||
// the first — and the first is the only copy taken before the app
|
||||
// rewrote the file with whatever it had in memory, i.e. the only one
|
||||
// that can still hold the full project list.
|
||||
let dir = temp_dir("second");
|
||||
let file = dir.join("projects.json");
|
||||
fs::write(&file, "original bytes").unwrap();
|
||||
let first = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
record_corrupt_load(&file, &first);
|
||||
|
||||
fs::write(&file, "degraded bytes").unwrap();
|
||||
let second = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
record_corrupt_load(&file, &second);
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("projects.json.bak")).unwrap(),
|
||||
"original bytes",
|
||||
"the first copy must survive the second corruption"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("projects.json.corrupt-20260601-000000.bak")).unwrap(),
|
||||
"degraded bytes"
|
||||
);
|
||||
// And the marker still dates the loss from the first failure, which is
|
||||
// when the project list actually stopped being complete.
|
||||
assert_eq!(
|
||||
fs::read_to_string(corrupt_marker_for(&file)).unwrap(),
|
||||
first.to_rfc3339()
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,47 +26,122 @@ const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"
|
||||
/// Fixed account name used for every triple-c keychain entry.
|
||||
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||
|
||||
/// Every per-project secret this app stores, and therefore every one it has to
|
||||
/// be able to delete.
|
||||
///
|
||||
/// This list is the **only** definition. It used to exist twice — once
|
||||
/// implicitly, as whatever `store_secrets_for_project` happened to write, and
|
||||
/// once explicitly, as a literal array inside `delete_project_secrets` — and
|
||||
/// the two drifted: `openai-compatible-api-key` was added to the writer and
|
||||
/// never to the deleter, so removing a project left a live provider API key in
|
||||
/// the user's login keychain with nothing left in the app that referenced it,
|
||||
/// or would ever offer to clean it up.
|
||||
///
|
||||
/// Drift is now a compile-time-shaped error rather than a review-time one:
|
||||
/// [`project_secret_entry`] refuses a key that is not in this list, so a new
|
||||
/// secret cannot be stored until it has been added here, and adding it here is
|
||||
/// what makes [`delete_project_secrets`] cover it.
|
||||
pub const PROJECT_SECRET_KEYS: &[&str] = &[
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
"openai-compatible-api-key",
|
||||
];
|
||||
|
||||
/// The keychain entry for one per-project secret, rejecting any key name not in
|
||||
/// [`PROJECT_SECRET_KEYS`]. See that constant for why the rejection matters.
|
||||
fn project_secret_entry(project_id: &str, key_name: &str) -> Result<keyring::Entry, String> {
|
||||
if !PROJECT_SECRET_KEYS.contains(&key_name) {
|
||||
return Err(format!(
|
||||
"Unknown project secret '{}'. Add it to PROJECT_SECRET_KEYS so project deletion \
|
||||
clears it too.",
|
||||
key_name
|
||||
));
|
||||
}
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
keyring::Entry::new(&service, KEYCHAIN_ACCOUNT).map_err(|e| format!("Keyring error: {}", e))
|
||||
}
|
||||
|
||||
/// Store a per-project secret in the OS keychain.
|
||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
project_secret_entry(project_id, key_name)?
|
||||
.set_password(value)
|
||||
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||
}
|
||||
|
||||
/// Retrieve a per-project secret from the OS keychain.
|
||||
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.get_password() {
|
||||
match project_secret_entry(project_id, key_name)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all known secrets for a project from the OS keychain.
|
||||
/// Delete one per-project secret, treating "wasn't there" as success.
|
||||
pub fn delete_project_secret(project_id: &str, key_name: &str) -> Result<(), String> {
|
||||
match project_secret_entry(project_id, key_name)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete project secret '{}': {}", key_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a per-project secret, or **clear** it when there is nothing to write.
|
||||
///
|
||||
/// This is the function every save path should call, and the reason it exists
|
||||
/// is that the obvious `if let Some(v) = … { store(v) }` is wrong. The editors
|
||||
/// in `components/projects/home/config/` send a blanked field as `null`
|
||||
/// (`AccessSection.tsx`: `save({ git_token: gitToken || null })`), so a `None`
|
||||
/// is a user asking for the secret to be *removed* — and skipping it left the
|
||||
/// old value in the keychain, where `load_secrets_for_project` read it straight
|
||||
/// back out and put it back on the project. Clearing a credential through the
|
||||
/// UI was therefore impossible: the field looked empty and the container kept
|
||||
/// getting the old token.
|
||||
///
|
||||
/// `Some("")` and `Some(" ")` are treated the same as `None` — a field the
|
||||
/// user emptied, whichever shape it arrives in — because a stored empty secret
|
||||
/// is not a secret, and `container_config` would inject it as an env var that
|
||||
/// overrides the unset case with a blank.
|
||||
// TODO(handoff): `commands/project_commands.rs::store_secrets_for_project` is
|
||||
// the one caller this is for, and it still uses the `if let Some(v) = … ` shape
|
||||
// that cannot clear anything. That file belongs to another change in this round,
|
||||
// so the switch is deliberately left to it; the six call sites there become
|
||||
// `store_or_clear_project_secret(&project.id, "<key>", field.as_deref())?`.
|
||||
#[allow(dead_code)]
|
||||
pub fn store_or_clear_project_secret(
|
||||
project_id: &str,
|
||||
key_name: &str,
|
||||
value: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
match secret_to_store(value) {
|
||||
Some(v) => store_project_secret(project_id, key_name, v),
|
||||
None => delete_project_secret(project_id, key_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The store-or-clear decision, split out so it can be tested without a
|
||||
/// keychain backend: `Some` means "write this", `None` means "remove whatever
|
||||
/// is there".
|
||||
#[allow(dead_code)]
|
||||
fn secret_to_store(value: Option<&str>) -> Option<&str> {
|
||||
match value.map(str::trim) {
|
||||
Some(v) if !v.is_empty() => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every known secret for a project from the OS keychain.
|
||||
///
|
||||
/// Called when a project is removed, so it must cover [`PROJECT_SECRET_KEYS`]
|
||||
/// exhaustively — a key missed here outlives the project that explained it.
|
||||
/// One key failing does not stop the rest: a partial cleanup that keeps going
|
||||
/// leaves strictly fewer credentials behind than one that gives up.
|
||||
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
||||
let secret_keys = [
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
];
|
||||
for key_name in &secret_keys {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {}
|
||||
Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||
}
|
||||
for key_name in PROJECT_SECRET_KEYS {
|
||||
if let Err(e) = delete_project_secret(project_id, key_name) {
|
||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -269,3 +344,78 @@ pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,78 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!--
|
||||
This page is served by an axum server bound 0.0.0.0 (remote access is the
|
||||
feature) behind a permissive CORS layer, and it fronts a shell in a container.
|
||||
It gets a CSP of its own because nothing else gives it one: the app's
|
||||
`tauri.conf.json` CSP covers the desktop webview, never this document.
|
||||
|
||||
`default-src 'none'` is the base, so anything not named below is refused
|
||||
outright. What is named:
|
||||
script-src jsdelivr for the three xterm bundles, plus 'unsafe-inline' for
|
||||
this page's own inline <script>. Nonces/hashes were considered
|
||||
and rejected: the file is a static `include_str!()` asset, so a
|
||||
hash would have to be recomputed by hand on every edit to the
|
||||
script, and the failure mode of getting that wrong is a terminal
|
||||
that silently will not start.
|
||||
style-src the xterm stylesheet, this page's <style>, and the one inline
|
||||
`style=` attribute below (style attributes need 'unsafe-inline').
|
||||
connect-src the WebSocket back to this same server. `ws:`/`wss:` as schemes
|
||||
rather than an origin, because the host and port are whatever
|
||||
the user reached this page on and are not knowable at build time.
|
||||
form-action / base-uri / object-src / frame-ancestors — all 'none'. Note
|
||||
`frame-ancestors` is ignored in a <meta> CSP; it is here as a
|
||||
statement of intent, and the real protection would be a response
|
||||
header from `server.rs`.
|
||||
Deliberately absent: 'unsafe-eval', and any origin other than jsdelivr.
|
||||
-->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'none';
|
||||
script-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||
style-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||
img-src 'self' data:;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self' ws: wss:;
|
||||
form-action 'none';
|
||||
base-uri 'none';
|
||||
object-src 'none';
|
||||
frame-ancestors 'none';
|
||||
">
|
||||
<title>Triple-C Web Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<!--
|
||||
Subresource Integrity on every CDN asset.
|
||||
|
||||
Without it this page executes whatever jsdelivr returns, inside a document
|
||||
that holds the web terminal's access token and drives a shell in a container —
|
||||
an upstream compromise, a hijacked package version or a MITM on a phone's
|
||||
network is arbitrary code with that reach. The hashes below were computed
|
||||
from the exact bytes at these pinned versions. `crossorigin="anonymous"` is
|
||||
required for SRI to be checked on a cross-origin fetch.
|
||||
|
||||
Bumping a version means recomputing its hash:
|
||||
curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
||||
A mismatched hash blocks the asset, so a stale hash shows up immediately as a
|
||||
terminal that does not render — never as an unverified load.
|
||||
|
||||
These are still remote loads: the remote terminal does not work with no
|
||||
internet on the client side, and vendoring the ~300 KB of minified xterm into
|
||||
this file would fix that. It was not done here — SRI already closes the
|
||||
integrity half, which is the security half, and the availability half is a
|
||||
separate call about binary size and diff readability.
|
||||
-->
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
||||
integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"
|
||||
crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"
|
||||
integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"
|
||||
integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"
|
||||
integrity="sha384-S1biLeI8L/bFduIVvCxbn/l4EtaG4nTqQjGF7qCYTbsGXGFe8KgIKXtw4+UWxprv"
|
||||
crossorigin="anonymous"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
@@ -334,6 +401,9 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
enterkeyhint="send" inputmode="text">
|
||||
<button class="key-btn" id="btnEnter">Enter</button>
|
||||
<!-- A newline *without* submitting. There is no Shift on a phone keyboard,
|
||||
so the chord the desktop app binds needs a key of its own here. -->
|
||||
<button class="key-btn" id="btnNewline" title="Insert a newline without submitting (Shift+Enter)">↵+</button>
|
||||
<button class="key-btn" id="btnTab">Tab</button>
|
||||
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||
</div>
|
||||
@@ -360,6 +430,19 @@
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const mobileInput = document.getElementById('mobileInput');
|
||||
const btnEnter = document.getElementById('btnEnter');
|
||||
const btnNewline = document.getElementById('btnNewline');
|
||||
|
||||
// Whether the *active* session understands ESC+CR as "insert a newline".
|
||||
//
|
||||
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
|
||||
// sending it there is a silent no-op — which is worse from the mobile bar
|
||||
// than from a hardware key, because the bar puts a dedicated button on
|
||||
// screen that appears to do nothing. The xterm key handler is already scoped
|
||||
// this way; these two paths were not.
|
||||
function activeSessionTakesEscCr() {
|
||||
const s = activeSessionId && sessions[activeSessionId];
|
||||
return !!s && s.type === 'claude';
|
||||
}
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
@@ -519,7 +602,7 @@
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
@@ -570,8 +653,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||
// Prefer the type the *server* reports for this session. The old path read
|
||||
// a single `pendingSessionType` global set at request time, so opening two
|
||||
// sessions before the first reply landed swapped their labels — routine on
|
||||
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||
// sends a bare CR and submits a half-written prompt.
|
||||
//
|
||||
// The fallback keeps an older server working, and defaults to `claude`,
|
||||
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||
// a bare CR in Claude Code loses the prompt.
|
||||
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
@@ -647,6 +740,34 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||
// submitting it. xterm.js does not consult `shiftKey` for Enter, so
|
||||
// without this Shift+Enter is byte-identical to Enter.
|
||||
//
|
||||
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
|
||||
// meta, and it is the same sequence its own `/terminal-setup` installs for
|
||||
// VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that
|
||||
// also works in Claude Code, but a shell would *run* the line, so the two
|
||||
// session types would diverge. Claude sessions only, for that reason —
|
||||
// `bash -l` has no readline binding for `\e\r`.
|
||||
term.attachCustomKeyEventHandler(e => {
|
||||
if (
|
||||
e.type === 'keydown' && e.key === 'Enter' && e.shiftKey &&
|
||||
!e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
|
||||
sessionType === 'claude'
|
||||
) {
|
||||
sendTerminalInput('\x1b\r');
|
||||
// `preventDefault()` is what stops the submit, not the `return false`.
|
||||
// xterm's `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still fires and emits a bare CR for Enter — inserting the
|
||||
// newline and then submitting the prompt anyway. See the same comment
|
||||
// in TerminalView.tsx.
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Track scroll position for scroll-to-bottom button
|
||||
term.onScroll(() => updateScrollButton());
|
||||
|
||||
@@ -698,6 +819,7 @@
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
syncNewlineButton();
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -724,6 +846,7 @@
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
syncNewlineButton();
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -799,7 +922,11 @@
|
||||
sendTerminalInput(val);
|
||||
mobileInput.value = '';
|
||||
}
|
||||
sendTerminalInput('\r');
|
||||
// Shift+Enter is a newline, not a submit — same bytes, and the same
|
||||
// reasoning, as the terminal's own key handler above. A hardware
|
||||
// keyboard on a tablet is the only way to reach this; the phone case is
|
||||
// the dedicated newline button beside Enter.
|
||||
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
sendTerminalInput('\t');
|
||||
@@ -807,6 +934,27 @@
|
||||
});
|
||||
|
||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => {
|
||||
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
|
||||
sendTerminalInput('\x1b\r');
|
||||
mobileInput.focus();
|
||||
};
|
||||
|
||||
// Keep the button's affordance honest: on a shell tab there is no byte that
|
||||
// means "newline without running the line", so the control is disabled
|
||||
// rather than left looking live.
|
||||
function syncNewlineButton() {
|
||||
const usable = activeSessionTakesEscCr();
|
||||
btnNewline.disabled = !usable;
|
||||
btnNewline.title = usable
|
||||
? 'Insert a newline without submitting (Shift+Enter)'
|
||||
: 'Only Claude sessions support this — a shell runs the line instead';
|
||||
}
|
||||
|
||||
// With no session open yet, `activeSessionTakesEscCr()` is already false —
|
||||
// but nothing had called this, so the button rendered live before the first
|
||||
// tab existed.
|
||||
syncNewlineButton();
|
||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
||||
Opened {
|
||||
session_id: String,
|
||||
project_name: String,
|
||||
/// Echoed back so the client can label the session from the reply
|
||||
/// rather than from a global set at request time.
|
||||
///
|
||||
/// Without it the client correlates through a single
|
||||
/// `pendingSessionType`, so opening two sessions before the first
|
||||
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||
/// Claude session mislabelled as a shell now submits a half-written
|
||||
/// prompt instead of inserting a newline.
|
||||
session_type: String,
|
||||
},
|
||||
Output {
|
||||
session_id: String,
|
||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
||||
let _ = out_tx.send(ServerMessage::Opened {
|
||||
session_id,
|
||||
project_name,
|
||||
// Derived from the same match that chose `cmd` above, not echoed from
|
||||
// the request: anything that is not exactly "bash" runs Claude, so
|
||||
// echoing the raw value would label an unrecognised string as its own
|
||||
// type and put the client back where it started.
|
||||
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827; form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+27
-8
@@ -9,6 +9,7 @@ import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||
import ToastHost from "./components/ui/ToastHost";
|
||||
import { PaneVisibilityProvider } from "./components/ui/PaneVisibility";
|
||||
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||
import Button from "./components/ui/Button";
|
||||
import { useDocker } from "./hooks/useDocker";
|
||||
@@ -128,19 +129,34 @@ export default function App() {
|
||||
<WelcomeScreen />
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
{/* Every tab stays mounted and the inactive ones are merely
|
||||
`hidden`, which a dialog's portal to `document.body` does not
|
||||
inherit: a confirmation opened in one project stayed painted
|
||||
over whatever tab the user switched to, kept its focus trap,
|
||||
and — being a blocking overlay — refused every native file
|
||||
drop in the window. `PaneVisibilityProvider` is how a `Modal`
|
||||
inside a pane finds out the pane stepped aside. */}
|
||||
{homeProjectIds.map((projectId) => (
|
||||
<ProjectHome
|
||||
<PaneVisibilityProvider
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
active={activeTabKey === homeTabKey(projectId)}
|
||||
/>
|
||||
visible={activeTabKey === homeTabKey(projectId)}
|
||||
>
|
||||
<ProjectHome
|
||||
projectId={projectId}
|
||||
active={activeTabKey === homeTabKey(projectId)}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<TerminalView
|
||||
<PaneVisibilityProvider
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
visible={session.id === activeSessionId}
|
||||
>
|
||||
<TerminalView
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -156,6 +172,9 @@ export default function App() {
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
/* Covers the whole window, so no pane underneath may accept a
|
||||
native file drop while it is up — see `lib/dropTarget.ts`. */
|
||||
data-blocks-drop="true"
|
||||
data-testid="shutdown-overlay"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderMarkdown } from "./HelpDialog";
|
||||
|
||||
/**
|
||||
* `renderMarkdown` builds HTML by regex substitution and the result is handed
|
||||
* to `dangerouslySetInnerHTML`. Its input is the help document, which is
|
||||
* fetched from GitHub at runtime — remote, versioned by someone else, and not
|
||||
* something the app gets to trust. These tests pin the escaping.
|
||||
*/
|
||||
/**
|
||||
* Parse rendered HTML and return its first anchor, asserting that *no* element
|
||||
* anywhere in the output grew an attribute outside the allowed set. A broken
|
||||
* attribute value is only interesting if it becomes an attribute, so the check
|
||||
* has to run through a real parser rather than over the string.
|
||||
*/
|
||||
const ALLOWED_ATTRS = new Set(["class", "href", "target", "rel", "id"]);
|
||||
|
||||
function onlyAnchor(html: string): HTMLAnchorElement {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
||||
for (const name of attrNames(el)) {
|
||||
expect(ALLOWED_ATTRS.has(name), `unexpected attribute ${name}`).toBe(true);
|
||||
}
|
||||
}
|
||||
const anchors = doc.querySelectorAll("a");
|
||||
expect(anchors.length).toBeGreaterThan(0);
|
||||
return anchors[0] as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
/** Attribute names the parser actually saw on an element. */
|
||||
function attrNames(el: Element): string[] {
|
||||
return Array.from(el.attributes).map((a) => a.name);
|
||||
}
|
||||
|
||||
describe("renderMarkdown escaping", () => {
|
||||
it("escapes the quote characters an attribute value is delimited by", () => {
|
||||
const html = renderMarkdown('He said "hi" and it\'s fine.');
|
||||
expect(html).not.toMatch(/said "hi"/);
|
||||
expect(html).toContain(""hi"");
|
||||
expect(html).toContain("it's");
|
||||
});
|
||||
|
||||
it("does not let a link target break out of href=\"…\"", () => {
|
||||
// The sink: the URL capture is `[^)]+`, which includes `"` and spaces, and
|
||||
// the value lands directly inside `href="…"`. Asserted through the DOM,
|
||||
// not by string matching — the payload text legitimately survives *inside*
|
||||
// the attribute value; what must not happen is it becoming an attribute.
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown(
|
||||
'[click](https://example.com/" onmouseover="steal() formaction="https://evil.example)',
|
||||
),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||
expect(a.getAttribute("href")).toContain('" onmouseover="');
|
||||
});
|
||||
|
||||
it("does not let an in-document anchor break out of href=\"#…\"", () => {
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown('[jump](#top" onfocus="steal() autofocus="x)'),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href"]);
|
||||
});
|
||||
|
||||
it("does not let a bare URL break out of href=\"…\"", () => {
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown('See https://example.com/a"onmouseover="steal()\n'),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||
});
|
||||
|
||||
it("still renders ordinary links intact", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/a?x=1&y=2)");
|
||||
// `&` was entity-escaped by the first pass and must not be escaped twice.
|
||||
expect(html).toContain('href="https://example.com/a?x=1&y=2"');
|
||||
expect(html).not.toContain("&amp;");
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain(">docs</a>");
|
||||
});
|
||||
|
||||
it("still renders an in-document anchor link intact", () => {
|
||||
const html = renderMarkdown("[jump](#getting-started)");
|
||||
expect(html).toContain('href="#getting-started"');
|
||||
});
|
||||
|
||||
it("keeps header slugs stable across the new quote escaping", () => {
|
||||
// The regression this guards: quotes now become entities *before*
|
||||
// `slugify` sees them, and an entity's letters would otherwise survive
|
||||
// into the id ("claude39s-setup"), silently breaking every
|
||||
// `[…](#claudes-setup)` in the document.
|
||||
expect(renderMarkdown("## Claude's setup")).toContain('id="claudes-setup"');
|
||||
expect(renderMarkdown('## The "safe" mode')).toContain('id="the-safe-mode"');
|
||||
});
|
||||
|
||||
it("still refuses to emit raw tags from the source document", () => {
|
||||
const html = renderMarkdown("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -12,21 +12,67 @@ function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||
// Quote characters are escaped to entities before this runs (see
|
||||
// `renderMarkdown`). Drop those two entities whole, so a header with an
|
||||
// apostrophe or a quote slugifies to what it did when the character was
|
||||
// simply stripped — otherwise every such anchor id silently changes and
|
||||
// the in-document links pointing at it stop resolving. `&`/`<`/
|
||||
// `>` are deliberately not in this list: they were already entities
|
||||
// before, so their existing (odd) slugs are the established ones.
|
||||
.replace(/"|'/g, "")
|
||||
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||
.replace(/\s+/g, "-") // spaces to dashes
|
||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML converter for the help content. */
|
||||
function renderMarkdown(md: string): string {
|
||||
/**
|
||||
* Escape a captured markdown value that is about to be interpolated into an
|
||||
* HTML *attribute* value.
|
||||
*
|
||||
* `renderMarkdown` entity-escapes the whole document first, but that pass only
|
||||
* covered `&`, `<` and `>` — not the quote characters, which is all an
|
||||
* attribute value is delimited by. `[x](https://a" onload="…)` therefore closed
|
||||
* `href="` and started a new attribute, because the URL capture is `[^)]+` and
|
||||
* `"` is in `[^)]`. The document is remote GitHub markdown, so that capture is
|
||||
* not ours to trust.
|
||||
*
|
||||
* Only quotes are escaped here: `&`, `<` and `>` have already been converted by
|
||||
* the caller, and re-escaping the `&` would double-encode every `&` in a
|
||||
* query string.
|
||||
*/
|
||||
function attr(value: string): string {
|
||||
return value.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown-to-HTML converter for the help content.
|
||||
*
|
||||
* Exported for `HelpDialog.test.tsx`: the output goes to
|
||||
* `dangerouslySetInnerHTML`, so the escaping rules below are security rules and
|
||||
* need to be asserted rather than assumed.
|
||||
*/
|
||||
export function renderMarkdown(md: string): string {
|
||||
let html = md;
|
||||
|
||||
// Normalize line endings
|
||||
html = html.replace(/\r\n/g, "\n");
|
||||
|
||||
// Escape HTML entities (but we'll re-introduce tags below)
|
||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// Escape HTML entities (but we'll re-introduce tags below).
|
||||
//
|
||||
// The quote characters are part of this on purpose. Everything below builds
|
||||
// HTML by regex substitution, and several of those substitutions drop a
|
||||
// capture straight into an attribute value (`href="$2"`). Leaving `"` and `'`
|
||||
// live meant a link target could close the attribute and open another one —
|
||||
// in a document fetched from GitHub at runtime and handed to
|
||||
// `dangerouslySetInnerHTML`. Escaping here closes every such sink at the
|
||||
// source; `attr()` below is the belt to this pair of braces.
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// Fenced code blocks (```...```)
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
@@ -84,13 +130,15 @@ function renderMarkdown(md: string): string {
|
||||
// Markdown-style anchor links [text](#anchor)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||
'<a class="help-link" href="#$2">$1</a>',
|
||||
(_m, text: string, anchor: string) =>
|
||||
`<a class="help-link" href="#${attr(anchor)}">${text}</a>`,
|
||||
);
|
||||
|
||||
// Markdown-style external links [text](url)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
(_m, text: string, url: string) =>
|
||||
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||
);
|
||||
|
||||
// Unordered list items (- ...)
|
||||
@@ -117,7 +165,8 @@ function renderMarkdown(md: string): string {
|
||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||
html = html.replace(
|
||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
(_m, url: string) =>
|
||||
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||
);
|
||||
|
||||
// Wrap remaining loose text lines in paragraphs
|
||||
|
||||
@@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) {
|
||||
}))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
// Only in a Claude tab: the chord is bound there and nowhere else, and a hint
|
||||
// for a key that does nothing is worse than no hint.
|
||||
const inClaudeSession = sessions.some(
|
||||
(s) => s.id === activeSessionId && s.sessionType === "claude",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||
@@ -45,6 +50,14 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!terminalHasSelection && inClaudeSession && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span title="Sends ESC+CR — the sequence Claude Code's own /terminal-setup installs. Alt+Enter does the same.">
|
||||
Shift+Enter: newline
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{activeSessionId && !terminalAtBottom && (
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
|
||||
function renderEditor(
|
||||
settings: ClaudeCodeSettings | null,
|
||||
scope: "global" | "project" = "global",
|
||||
) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope={scope}
|
||||
settings={settings}
|
||||
disabled={false}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
return onSave;
|
||||
}
|
||||
|
||||
describe("ClaudeCodeSettingsEditor", () => {
|
||||
it("shows the two default-on settings as on for a project that never touched them", () => {
|
||||
// Claude Code's session recap and fullscreen auto-scroll are both on by
|
||||
// default, and the fields behind them store the *disabled* sense. A toggle
|
||||
// rendered straight from the field would tell every existing user their
|
||||
// recap is off.
|
||||
renderEditor(null);
|
||||
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("stores the disabled sense when an inverted toggle is switched off", () => {
|
||||
const onSave = renderEditor(null);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses back to null once every setting is at its default again", () => {
|
||||
// `null` is what tells the backend this project adds nothing over the
|
||||
// global settings, so the round trip has to land exactly back on it.
|
||||
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("offers the classic renderer as a choice distinct from automatic", () => {
|
||||
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
|
||||
// different, and previously unreachable, instruction.
|
||||
const onSave = renderEditor(null);
|
||||
const tui = screen.getByLabelText("TUI mode");
|
||||
expect(
|
||||
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "default", "fullscreen"]);
|
||||
fireEvent.change(tui, { target: { value: "default" } });
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
|
||||
});
|
||||
|
||||
it("offers every effort level Claude Code accepts", () => {
|
||||
// Verified against the shipped `claude` binary's own schema rather than
|
||||
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||
// checked externally — which is the whole weakness of this test. It can
|
||||
// only prove the editor agrees with this list, never that the list is the
|
||||
// one Claude Code reads. The same blind spot is why `effort` and
|
||||
// `focusMode` were confidently wrong for months.
|
||||
renderEditor(null);
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||
).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
});
|
||||
|
||||
describe("project scope", () => {
|
||||
it("offers Global as a third state so a project can decline to have an opinion", () => {
|
||||
renderEditor(null, "project");
|
||||
const focus = screen.getByLabelText("Focus mode");
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
expect((focus as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("stores a deliberate false so the project can turn a global On back off", () => {
|
||||
// The reason the field widened from boolean to boolean|null. Under the
|
||||
// old merge there was no project value that could produce this.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focus_mode: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a deliberate off to null", () => {
|
||||
// `null` means inherit. Collapsing here would silently hand the setting
|
||||
// straight back to the global value the user just overrode.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).not.toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("round-trips the inverted fields through the disabled sense", () => {
|
||||
// Session recap stores `session_recap_disabled`, so choosing "off" has to
|
||||
// store `true` and choosing "on" has to store `false`.
|
||||
const onSave = renderEditor(null, "project");
|
||||
const recap = screen.getByLabelText("Session recap");
|
||||
|
||||
fireEvent.change(recap, { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: true }),
|
||||
);
|
||||
|
||||
fireEvent.change(recap, { target: { value: "on" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a stored override rather than the inherited state", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-scroll is the second inverted field and had no project-scope test at
|
||||
* all — every assertion above rides on `session_recap_disabled`, so a
|
||||
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
|
||||
* one of the two and pass silently for the other. It is stored as
|
||||
* `auto_scroll_disabled`, so every value here reads back the other way up.
|
||||
*/
|
||||
describe("auto-scroll", () => {
|
||||
const AUTO = "Auto-scroll";
|
||||
|
||||
it("starts on Global, which is not the same as on", () => {
|
||||
// Claude Code scrolls by default, so an inheriting project *behaves*
|
||||
// as on — but it has taken no position, and rendering it as "On" would
|
||||
// make a later global change look like it had no effect.
|
||||
renderEditor(null, "project");
|
||||
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("stores the disabled sense in both directions", () => {
|
||||
const onSave = renderEditor(null, "project");
|
||||
const auto = screen.getByLabelText(AUTO);
|
||||
|
||||
fireEvent.change(auto, { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_scroll_disabled: true }),
|
||||
);
|
||||
|
||||
fireEvent.change(auto, { target: { value: "on" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_scroll_disabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the setting back to the global level when Global is chosen", () => {
|
||||
// Back to no opinion, and with nothing else set that collapses the
|
||||
// whole object to `null` — the value that means "adds nothing over the
|
||||
// global settings".
|
||||
const onSave = renderEditor(
|
||||
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
|
||||
"project",
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
|
||||
expect(onSave).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("reads a stored override back the right way up", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
|
||||
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The inverted fields store a *deviation*, so a stored `false` is the one
|
||||
* value that means "the user deliberately re-enabled the default". Nothing
|
||||
* asserted it: every existing test drives the `true` (turned off) direction
|
||||
* or the `null` (untouched) one, and both scopes would still read correctly
|
||||
* if the inversion were dropped from the `false` branch alone.
|
||||
*/
|
||||
describe.each([
|
||||
["session_recap_disabled", "Session recap"] as const,
|
||||
["auto_scroll_disabled", "Auto-scroll"] as const,
|
||||
])("a stored false on %s", (key, label) => {
|
||||
it("reads as On at project scope, not as Off", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
|
||||
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
|
||||
});
|
||||
|
||||
it("reads as on at global scope, where the control is a switch", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
|
||||
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
||||
* struct skips serialising a field it has no value for, which is what keeps
|
||||
* an older binary able to parse `projects.json` after a downgrade. It is also
|
||||
* the exact shape a project stored before the fields were widened is read
|
||||
* back as — every one of its `false`s meant "unset" — so reading absent as
|
||||
* "off" would show a switch the user never touched as a deliberate choice.
|
||||
*/
|
||||
it("reads an absent field as Global rather than as Off", () => {
|
||||
renderEditor({} as ClaudeCodeSettings, "project");
|
||||
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("still collapses to null when an absent-field object is edited back", () => {
|
||||
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
||||
// Off and straight back on: the round trip has to land on `null`, or an
|
||||
// untouched global stops being indistinguishable from one never opened.
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -8,52 +8,91 @@ interface Props {
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||
/**
|
||||
* `"project"` adds a third "Global" state to every switch, because a project
|
||||
* has somewhere to inherit *from*. The global editor has no such fallback —
|
||||
* unset there just means Claude Code's own default — so it stays a plain
|
||||
* on/off and never renders the extra choice.
|
||||
*/
|
||||
scope?: "global" | "project";
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
enable_session_recap: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: null,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* "Nothing is set at this level", which is saved as `null` rather than as a
|
||||
* struct of nulls so that a project with no opinion is indistinguishable from
|
||||
* one that never opened this editor.
|
||||
*
|
||||
* Note `false` is *not* a default any more: it is a deliberate off that
|
||||
* overrides a global on, so a settings object holding one has to be persisted.
|
||||
*/
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
|
||||
// The Rust struct skips serialising one it has no value for, so a project
|
||||
// whose stored settings were all "unset" arrives here as `{}` — and reading
|
||||
// that as "off" is exactly the mistake the three-state control exists to
|
||||
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.enable_session_recap === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
s.tui_mode == null &&
|
||||
s.effort == null &&
|
||||
s.auto_scroll_disabled == null &&
|
||||
s.focus_mode == null &&
|
||||
s.show_thinking_summaries == null &&
|
||||
s.session_recap_disabled == null &&
|
||||
s.env_scrub == null &&
|
||||
s.prompt_caching_1h == null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two of Claude Code's settings are **on by default**, so the field behind them
|
||||
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
|
||||
* — that is what makes an untouched project mean "leave Claude Code alone"
|
||||
* rather than "the user turned this off". `invert` is what lets those still
|
||||
* read as an ordinary on/off switch here: the toggle shows the feature's state,
|
||||
* the field stores the deviation from the default.
|
||||
*/
|
||||
const BOOLEAN_FIELDS: {
|
||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||
label: string;
|
||||
hint: string;
|
||||
invert?: boolean;
|
||||
}[] = [
|
||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||
{
|
||||
key: "focus_mode",
|
||||
label: "Focus mode",
|
||||
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||
// which is the complaint that started this whole round of work.
|
||||
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||
},
|
||||
{
|
||||
key: "show_thinking_summaries",
|
||||
label: "Thinking summaries",
|
||||
hint: "Shows Claude's thinking process as summaries.",
|
||||
},
|
||||
{
|
||||
key: "enable_session_recap",
|
||||
key: "session_recap_disabled",
|
||||
label: "Session recap",
|
||||
hint: "Provides context when returning to a session.",
|
||||
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: "auto_scroll_disabled",
|
||||
label: "Auto-scroll disabled",
|
||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||
label: "Auto-scroll",
|
||||
hint: "Follows new output to the bottom in fullscreen rendering.",
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: "env_scrub",
|
||||
@@ -72,6 +111,7 @@ export default function ClaudeCodeSettingsEditor({
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
scope = "global",
|
||||
}: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||
@@ -95,9 +135,16 @@ export default function ClaudeCodeSettingsEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
|
||||
the renderer for itself, which is not the same as pinning the classic
|
||||
one — and the key is now always written (or explicitly deleted), so
|
||||
"Automatic" has to be selectable rather than merely being what you get
|
||||
when nothing is emitted.
|
||||
*/}
|
||||
<SwitchRow
|
||||
label="TUI mode"
|
||||
hint="Enables flicker-free alt-screen rendering."
|
||||
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
|
||||
control={
|
||||
<select
|
||||
value={local.tui_mode ?? ""}
|
||||
@@ -106,7 +153,8 @@ export default function ClaudeCodeSettingsEditor({
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="">Automatic</option>
|
||||
<option value="default">Classic</option>
|
||||
<option value="fullscreen">Fullscreen</option>
|
||||
</select>
|
||||
}
|
||||
@@ -127,25 +175,79 @@ export default function ClaudeCodeSettingsEditor({
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">Extra high</option>
|
||||
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||
against the shipped claude binary's own schema, not just docs. */}
|
||||
<option value="max">Maximum</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => {
|
||||
const stored = local[key];
|
||||
|
||||
if (scope === "global") {
|
||||
// No level above this one to inherit from, so "unset" and "off" are
|
||||
// the same instruction here and a plain switch is the honest control.
|
||||
// Unset therefore has to *display* as Claude Code's own default —
|
||||
// which for the two inverted fields is on, not off.
|
||||
const checked = invert ? stored !== true : stored === true;
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
checked={local[key]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
label={label}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(v) => {
|
||||
// Collapse back to null at the default rather than storing
|
||||
// a redundant `false`, so an untouched global stays
|
||||
// indistinguishable from one that was never opened.
|
||||
const atDefault = invert ? v : !v;
|
||||
apply({
|
||||
[key]: atDefault ? null : invert ? !v : v,
|
||||
} as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
// `stored` holds the deviation from Claude Code's default, so an
|
||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||
const selected =
|
||||
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<select
|
||||
value={selected}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const choice = e.target.value;
|
||||
const next =
|
||||
choice === "global" ? null : invert ? choice === "off" : choice === "on";
|
||||
apply({ [key]: next } as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="on">On</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
Everything remove_project() destroys, named. It removes the container,
|
||||
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
|
||||
the triple-c-snapshot-{id} image and the project's keychain secrets — so
|
||||
an accurate warning has to reach past "the config volume". The last
|
||||
sentence is the reassuring half and matters just as much: project folders
|
||||
are bind mounts from the host and nothing here touches them.
|
||||
*/}
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||
delete the container, config volume, and stored credentials.
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||
its container, both of its volumes and its saved container image — so the home
|
||||
directory, the Claude login and config, installed skills, session transcripts,
|
||||
scheduled tasks and any stored credentials all go with it.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||
Your project folders on this machine are mounted in, not copied, and are left
|
||||
untouched.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -571,4 +571,57 @@ describe("BrowserTab", () => {
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the pane's sandbox: same-origin kept, top-navigation never granted", async () => {
|
||||
await renderLive();
|
||||
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||||
const tokens = new Set(
|
||||
(frame.getAttribute("sandbox") ?? "").split(/\s+/).filter(Boolean),
|
||||
);
|
||||
|
||||
// What is framed here is served by a process *inside* the container, which
|
||||
// is the untrusted side of this app. A top-navigation grant would let that
|
||||
// page set `top.location` and steer the whole Triple-C app window away from
|
||||
// itself — a sandbox escape from the app's point of view — and
|
||||
// `allow-popups-to-escape-sandbox` is the same hole one step removed: it
|
||||
// hands a popup an entirely unsandboxed context. Checked token by token so
|
||||
// the failure names the one that was added.
|
||||
for (const forbidden of [
|
||||
"allow-top-navigation",
|
||||
"allow-top-navigation-by-user-activation",
|
||||
"allow-top-navigation-to-custom-protocols",
|
||||
"allow-popups-to-escape-sandbox",
|
||||
]) {
|
||||
expect(
|
||||
tokens.has(forbidden),
|
||||
`FORBIDDEN iframe sandbox token "${forbidden}" on the browser view pane. ` +
|
||||
"A page served from inside the container could then navigate the whole " +
|
||||
"Triple-C app window away from itself (or run a popup unsandboxed) — a " +
|
||||
"sandbox escape. Remove it from the iframe in BrowserTab.tsx.",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// `allow-same-origin` must stay. The browser_view proxy's token gate
|
||||
// recognises the pane's own sub-resource requests by their `Origin`/
|
||||
// `Referer` header; dropping this token gives the frame an opaque origin,
|
||||
// which sends `null`, so the proxy refuses those requests and the pane
|
||||
// renders blank.
|
||||
expect(
|
||||
tokens.has("allow-same-origin"),
|
||||
'REQUIRED iframe sandbox token "allow-same-origin" is missing from the ' +
|
||||
"browser view pane. Without it the frame has an opaque origin and sends " +
|
||||
"`Origin: null`, which the browser_view proxy's token gate refuses — the " +
|
||||
"pane goes blank.",
|
||||
).toBe(true);
|
||||
|
||||
// And the exact set, so any *other* new grant is a deliberate edit here too.
|
||||
expect([...tokens].sort()).toEqual([
|
||||
"allow-downloads",
|
||||
"allow-forms",
|
||||
"allow-modals",
|
||||
"allow-popups",
|
||||
"allow-same-origin",
|
||||
"allow-scripts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,6 +457,35 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// host-side gate checks before anything reaches the container.
|
||||
src={status.url ?? undefined}
|
||||
title={`Playwright browser view for ${project.name}`}
|
||||
// What is framed here is served by a process inside the container,
|
||||
// which is the untrusted side of this app. Unsandboxed, it could
|
||||
// simply set `top.location` and navigate the *app's* webview
|
||||
// somewhere of its choosing — the frame is cross-origin, so it cannot
|
||||
// read the app, but steering the whole window is not something a
|
||||
// viewer pane should be able to do.
|
||||
//
|
||||
// The allowances are what the Playwright dashboard actually needs and
|
||||
// no more:
|
||||
// allow-scripts — it is an application, not a document.
|
||||
// allow-same-origin — it must reach its own WebSocket and assets,
|
||||
// and the host-side gate recognises the pane's
|
||||
// own sub-resource requests by their
|
||||
// `Origin`/`Referer`; an opaque origin would
|
||||
// send `null` and be refused. This does not
|
||||
// grant access to *this* app: 127.0.0.1:4782x
|
||||
// is a different origin from the app's.
|
||||
// allow-forms/-modals/-downloads/-popups — dashboard UI affordances
|
||||
// (trace download, confirm dialogs, opening a
|
||||
// page in a new window).
|
||||
//
|
||||
// Deliberately absent, and the point of the attribute:
|
||||
// `allow-top-navigation`, `allow-top-navigation-by-user-activation`
|
||||
// and `allow-popups-to-escape-sandbox`. Do not add them.
|
||||
//
|
||||
// No `referrerPolicy` either: the gate in `browser_view/proxy.rs`
|
||||
// reads the token out of a same-origin `Referer`, so stripping it
|
||||
// would break the pane.
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-downloads allow-popups"
|
||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||
/>
|
||||
) : live ? (
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../../../lib/types";
|
||||
import { readContainerFile } from "../../../lib/tauri-commands";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
import { formatBytes } from "./format";
|
||||
import {
|
||||
decodeBase64,
|
||||
imageMimeFor,
|
||||
looksBinary,
|
||||
previewKind,
|
||||
previewLimit,
|
||||
} from "./filePreview";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Preview =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** Too big to render whole — said so rather than shown as a half-file. */
|
||||
| { kind: "too-large" }
|
||||
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
||||
| { kind: "image"; url: string }
|
||||
| { kind: "unsupported" };
|
||||
|
||||
/**
|
||||
* Read-only preview of one container file.
|
||||
*
|
||||
* Images are rendered from a `blob:` URL rather than a `data:` one — the object
|
||||
* URL is revocable (so the bytes are released the moment the modal closes) and
|
||||
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
|
||||
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
|
||||
*/
|
||||
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
||||
|
||||
/**
|
||||
* The object URL currently on screen.
|
||||
*
|
||||
* This used to be an effect-local variable revoked from the effect's own
|
||||
* cleanup, which runs *before* the replacement effect body — so switching
|
||||
* entries (or any re-run of the effect for the same entry) released the URL
|
||||
* the `<img>` was still pointing at, and a blank image was the result until
|
||||
* the new read landed. If the new read failed, it stayed blank. So the
|
||||
* hand-over is explicit instead: a URL is revoked only once its replacement
|
||||
* exists, and unmount is what releases the last one.
|
||||
*/
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
/** Release the previous URL now that something else is on screen. */
|
||||
const replaceObjectUrl = (next: string | null) => {
|
||||
const previous = objectUrlRef.current;
|
||||
objectUrlRef.current = next;
|
||||
if (previous && previous !== next) URL.revokeObjectURL(previous);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const wantImage = previewKind(entry.name) === "image";
|
||||
const result = await readContainerFile(projectId, entry.path, previewLimit(entry.name));
|
||||
if (cancelled) return;
|
||||
|
||||
const bytes = decodeBase64(result.contents_base64);
|
||||
|
||||
if (wantImage) {
|
||||
// A truncated image is not a smaller image, it is a broken one.
|
||||
if (result.truncated) {
|
||||
setPreview({ kind: "too-large" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// The replacement is in hand, so the previous one can go.
|
||||
setPreview({ kind: "image", url });
|
||||
replaceObjectUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksBinary(bytes)) {
|
||||
setPreview({ kind: "unsupported" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview({
|
||||
kind: "text",
|
||||
text: new TextDecoder().decode(bytes),
|
||||
truncated: result.truncated,
|
||||
shownBytes: bytes.length,
|
||||
trueSize: result.size,
|
||||
});
|
||||
replaceObjectUrl(null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setPreview({ kind: "error", message: String(e) });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, entry.name, entry.path]);
|
||||
|
||||
// The bytes are released when the dialog goes, which is the whole reason the
|
||||
// preview is a `blob:` URL rather than a `data:` one.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={entry.name}
|
||||
description={`${entry.path} · ${formatBytes(entry.size)}`}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
widthClassName="w-[52rem]"
|
||||
>
|
||||
{preview.kind === "loading" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">Loading…</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "error" && (
|
||||
<p role="alert" className="text-[13px] text-[var(--error)]">
|
||||
{preview.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "text" && (
|
||||
<>
|
||||
{preview.truncated && (
|
||||
<p className="mb-2 text-xs text-[var(--warning)]">
|
||||
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
|
||||
</p>
|
||||
)}
|
||||
{/* Focusable, and its own scroll container, because a megabyte of
|
||||
text in an unfocusable `<pre>` is reachable by mouse wheel and by
|
||||
nothing else — no PageDown, no arrows, no keyboard at all. A
|
||||
scrollable region needs an accessible name to be worth landing
|
||||
on, hence the role and label. No `focus:outline-none`: the global
|
||||
`:focus-visible` ring is what says where the caret went. */}
|
||||
<pre
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={`${entry.name} contents`}
|
||||
className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]"
|
||||
>
|
||||
{preview.text}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preview.kind === "image" && (
|
||||
<img src={preview.url} alt={entry.name} className="max-w-full mx-auto" />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../../../store/appState", () => ({
|
||||
useAppState: { getState: () => ({ pushToast }) },
|
||||
}));
|
||||
|
||||
const toastText = () =>
|
||||
pushToast.mock.calls
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||
|
||||
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
name,
|
||||
path: `/workspace/${name}`,
|
||||
is_directory: false,
|
||||
is_symlink: false,
|
||||
size: 12,
|
||||
modified: "2024-05-01 10:00:00",
|
||||
permissions: "644",
|
||||
...extra,
|
||||
});
|
||||
|
||||
const contents = (text: string, extra: Partial<FileContents> = {}): FileContents => ({
|
||||
contents_base64: btoa(text),
|
||||
truncated: false,
|
||||
size: text.length,
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function renderTab() {
|
||||
const view = render(<FilesTab project={project} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
]);
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
describe("FilesTab listing", () => {
|
||||
it("lists /workspace once the container is running", async () => {
|
||||
await renderTab();
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says nothing about files while the container is stopped", async () => {
|
||||
render(<FilesTab project={{ ...project, status: "stopped" } as Project} />);
|
||||
expect(screen.getByText(/Start the container/)).toBeTruthy();
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels a symlink, which no longer masquerades as a plain file", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("app", { is_directory: true, is_symlink: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
expect(screen.getByTitle("Symbolic link")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab open semantics", () => {
|
||||
it("selects on a single click without navigating", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
fireEvent.click(screen.getByText("src"));
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("src").closest("tr")?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("navigates a directory on double click", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("walks the rows with the arrow keys, which is what makes the grid role honest", async () => {
|
||||
await renderTab();
|
||||
const first = screen.getByText("src").closest("tr")!;
|
||||
first.focus();
|
||||
fireEvent.keyDown(first, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(screen.getByText("notes.txt").closest("tr"));
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" });
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
it("opens a directory from the keyboard with Enter", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
// Not a tab stop — `..` holds the grid's single one until the arrows move
|
||||
// it — but still focusable and still openable from the keyboard.
|
||||
expect(row.getAttribute("tabindex")).toBe("-1");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab viewer", () => {
|
||||
it("shows a text file's contents in a dialog", async () => {
|
||||
readContainerFile.mockResolvedValue(contents("hello from the container"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(await screen.findByText("hello from the container")).toBeTruthy();
|
||||
// A text file gets the text-sized budget, not the image one.
|
||||
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", 1024 * 1024);
|
||||
});
|
||||
|
||||
it("renders an image through a revocable blob URL, not a data URI", async () => {
|
||||
// `data:` is absent from the app's img-src on purpose; `blob:` is what was
|
||||
// added, and the object URL has to be released when the dialog closes.
|
||||
listContainerFiles.mockResolvedValue([entry("logo.png", { size: 4 })]);
|
||||
readContainerFile.mockResolvedValue(contents("\x89PNG"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("logo.png"));
|
||||
});
|
||||
const img = (await screen.findByAltText("logo.png")) as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("blob:mock-url");
|
||||
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/logo.png", 5 * 1024 * 1024);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"));
|
||||
});
|
||||
|
||||
it("refuses an oversized image rather than drawing a half-decoded one", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("huge.png", { size: 40 * 1024 * 1024 })]);
|
||||
readContainerFile.mockResolvedValue(contents("\x89PNG", { truncated: true, size: 40 * 1024 * 1024 }));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("huge.png"));
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
readContainerFile.mockResolvedValue(
|
||||
contents("first megabyte", { truncated: true, size: 5 * 1024 * 1024 }),
|
||||
);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
expect(await screen.findByText(/Showing the first/)).toBeTruthy();
|
||||
expect(screen.getByText("first megabyte")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says there is no preview, and where to open the file instead", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("blob.bin"));
|
||||
});
|
||||
expect(await screen.findByText(/no preview for this file type/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab rename", () => {
|
||||
it("commits an inline rename on Enter and re-lists", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "renamed.txt");
|
||||
});
|
||||
|
||||
it("abandons the rename on Escape", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "nope.txt" } });
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
});
|
||||
expect(renameContainerPath).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("New name for notes.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a rename from the keyboard with F2", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
fireEvent.keyDown(row, { key: "F2" });
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a refused rename where it can be seen, not three hundred rows down", async () => {
|
||||
// The inline `error` div is the first child of the *scrolling* list, so
|
||||
// deep in a directory this used to be a rename box that stayed open and
|
||||
// said nothing. `ToastHost` is fixed, above the modal layer, and persists.
|
||||
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "x" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(toastText()).toContain("Permission denied");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
// The editor stays open, because the rename did not happen.
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab new folder", () => {
|
||||
it("creates a folder under the current directory", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "assets" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "assets");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid focus", () => {
|
||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||
// stops and Tab could not get out of the list.
|
||||
await renderTab();
|
||||
expect(gridRows()).toHaveLength(3); // .. , src, notes.txt
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "ArrowDown" });
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("src");
|
||||
expect(document.activeElement).toBe(tabStops()[0]);
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "End" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("notes.txt");
|
||||
fireEvent.keyDown(tabStops()[0], { key: "Home" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
});
|
||||
|
||||
it("keeps focus inside the grid after Enter opens a directory", async () => {
|
||||
// Rows are keyed by name, so navigating unmounts the focused `<tr>` — and
|
||||
// nothing used to re-focus, which ejected the user to `<body>`.
|
||||
await renderTab();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
row.focus();
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("index.ts", { path: "/workspace/src/index.ts" }),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect((document.activeElement as HTMLElement).closest("tr[data-file-row]")).toBeTruthy();
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts focus back on the row after a rename is abandoned", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
fireEvent.keyDown(row, { key: "F2" });
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "notes.txt"),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows a committed rename to the row's new name", async () => {
|
||||
// Explicit, because `clearAllMocks` clears calls but not implementations,
|
||||
// and an earlier test in this file leaves this one rejecting.
|
||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("renamed.txt"),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "renamed.txt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid semantics", () => {
|
||||
it("names its columns", async () => {
|
||||
await renderTab();
|
||||
for (const name of ["Name", "Size", "Modified", "Actions"]) {
|
||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("says folder or file in words, not in hue and a hidden emoji", async () => {
|
||||
await renderTab();
|
||||
const dir = screen.getByText("src").closest("tr")!;
|
||||
const plain = screen.getByText("notes.txt").closest("tr")!;
|
||||
expect(dir.textContent).toContain("Folder");
|
||||
expect(plain.textContent).toContain("File");
|
||||
});
|
||||
|
||||
it("keeps the visible label inside the accessible name (WCAG 2.5.3)", async () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// A `role="status"` node inserted already carrying its text is frequently
|
||||
// not announced at all, which is how every one of these went by in silence.
|
||||
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
expect(live.textContent).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
});
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "new" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
// Same node throughout — it is never unmounted.
|
||||
expect(screen.getByRole("status")).toBe(live);
|
||||
expect(live.textContent).toContain('Created "new"');
|
||||
});
|
||||
|
||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||
// The one failure that does *not* go to the toast host: it is on screen,
|
||||
// in context, and there is nothing for it to scroll behind.
|
||||
listContainerFiles.mockRejectedValue("Permission denied");
|
||||
await renderTab();
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,252 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Project } from "../../../lib/types";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** The old 42rem FileManager popup, now a main-area section. */
|
||||
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
/**
|
||||
* The project's file browser.
|
||||
*
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
* moved directory navigation onto double click too — a single click used to
|
||||
* navigate, which made it impossible to select a directory in order to rename
|
||||
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
|
||||
*
|
||||
* ## Focus, and why it is a roving tabindex
|
||||
*
|
||||
* Every row used to be `tabIndex={0}`, which made a 400-entry directory about
|
||||
* twelve hundred tab stops — Tab could not get *out* of the list, let alone
|
||||
* past it — and rows are keyed by name, so navigating unmounted the focused
|
||||
* `<tr>` and dropped focus to `<body>`: Enter on a directory ejected you from
|
||||
* the grid, arrows dead, Tab restarting from the top of the document. So
|
||||
* exactly one row carries `tabIndex={0}` (the *active* row), the arrows move
|
||||
* it, and a single effect below is responsible for putting focus back on a
|
||||
* sensible row after anything that re-renders the list.
|
||||
*/
|
||||
export default function FilesTab({ project }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
completed,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPaths,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
|
||||
/** The row the user has selected, by name — names are unique in a directory. */
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (running) navigate("/workspace");
|
||||
// Re-list when the container comes up.
|
||||
}, [navigate, running]);
|
||||
|
||||
// Leaving a directory invalidates every in-flight row interaction.
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setRenaming(null);
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creatingFolder) folderInputRef.current?.focus();
|
||||
}, [creatingFolder]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roving tabindex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every row's key, in visual order. The parent row is a row like any other. */
|
||||
const rowKeys = useMemo(
|
||||
() => [
|
||||
...(currentPath !== "/" ? [PARENT_ROW] : []),
|
||||
...entries.map((entry) => entry.name),
|
||||
],
|
||||
[currentPath, entries],
|
||||
);
|
||||
|
||||
/**
|
||||
* The active row, resolved against what is actually on screen. Keeping the
|
||||
* *intent* in state and resolving it at render time means a rename or a
|
||||
* deletion cannot leave the grid with no tab stop at all.
|
||||
*/
|
||||
const active = activeRow && rowKeys.includes(activeRow) ? activeRow : rowKeys[0];
|
||||
|
||||
const rowElement = useCallback((key: string): HTMLElement | undefined => {
|
||||
// Matched on the dataset rather than a selector, because a file name is
|
||||
// user data and can contain quotes, brackets and backslashes.
|
||||
const rows = paneRef.current?.querySelectorAll<HTMLElement>("tr[data-file-row]") ?? [];
|
||||
return Array.from(rows).find((row) => row.dataset.fileRow === key);
|
||||
}, []);
|
||||
|
||||
const focusRow = useCallback(
|
||||
(key: string) => {
|
||||
setActiveRow(key);
|
||||
rowElement(key)?.focus();
|
||||
},
|
||||
[rowElement],
|
||||
);
|
||||
|
||||
/**
|
||||
* Where focus should land the next time the grid re-renders, if it is loose.
|
||||
* `key` is a preference, not a promise — the row may not exist any more (a
|
||||
* rename that failed, a navigation into a different directory), in which case
|
||||
* the first row takes it.
|
||||
*/
|
||||
const wantFocus = useRef<{ key: string | null } | null>(null);
|
||||
|
||||
/**
|
||||
* The single place that decides where focus goes after the list changes.
|
||||
*
|
||||
* Runs after a navigation (rows are keyed by name, so the focused `<tr>` is
|
||||
* gone), after a rename commits or is abandoned, and after Escape. It never
|
||||
* *steals* focus: if the user has moved on to a button or the breadcrumb it
|
||||
* drops the request instead, so a background re-list cannot yank the caret
|
||||
* out from under them.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (renaming !== null) return; // the rename input owns focus
|
||||
const want = wantFocus.current;
|
||||
if (!want) return;
|
||||
wantFocus.current = null;
|
||||
|
||||
const focused = document.activeElement as HTMLElement | null;
|
||||
const loose =
|
||||
!focused ||
|
||||
focused === document.body ||
|
||||
focused === document.documentElement ||
|
||||
!!focused.closest?.("tr[data-file-row]");
|
||||
if (!loose) return;
|
||||
|
||||
const key = want.key && rowKeys.includes(want.key) ? want.key : rowKeys[0];
|
||||
if (key !== undefined) focusRow(key);
|
||||
}, [rowKeys, renaming, focusRow]);
|
||||
|
||||
/** Arrow / Home / End movement over the rows. */
|
||||
const moveActive = useCallback(
|
||||
(from: string, to: 1 | -1 | "first" | "last") => {
|
||||
if (rowKeys.length === 0) return;
|
||||
const i = rowKeys.indexOf(from);
|
||||
const next =
|
||||
to === "first"
|
||||
? 0
|
||||
: to === "last"
|
||||
? rowKeys.length - 1
|
||||
: Math.min(rowKeys.length - 1, Math.max(0, (i < 0 ? 0 : i) + to));
|
||||
focusRow(rowKeys[next]);
|
||||
},
|
||||
[rowKeys, focusRow],
|
||||
);
|
||||
|
||||
const startRename = useCallback((entry: FileEntry) => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
setRenameDraft(entry.name);
|
||||
setRenaming(entry.name);
|
||||
// Whichever way the rename ends, focus comes back to this row unless the
|
||||
// commit renames it — `commitRename` overwrites the preference below.
|
||||
wantFocus.current = { key: entry.name };
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
const renamedTo = renameDraft.trim();
|
||||
wantFocus.current = { key: renamedTo || entry.name };
|
||||
const done = await renameEntry(entry, renameDraft);
|
||||
if (done) setRenaming(null);
|
||||
},
|
||||
[renameEntry, renameDraft],
|
||||
);
|
||||
|
||||
const commitFolder = useCallback(async () => {
|
||||
const created = folderDraft.trim();
|
||||
const done = await createFolder(folderDraft);
|
||||
if (done) {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
wantFocus.current = { key: created || null };
|
||||
}
|
||||
}, [createFolder, folderDraft]);
|
||||
|
||||
/** Double click / Enter: directories navigate, files open the viewer. */
|
||||
const openEntry = useCallback(
|
||||
(entry: FileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
// The new listing's first row is `..`, which is the sensible landing
|
||||
// place: it is where you go to undo the step you just took.
|
||||
wantFocus.current = { key: null };
|
||||
navigate(entry.path);
|
||||
} else {
|
||||
setViewing(entry);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const openParent = useCallback(() => {
|
||||
// Coming back up, the directory just left is the interesting row.
|
||||
const leaving = currentPath.split("/").filter(Boolean).pop() ?? null;
|
||||
wantFocus.current = { key: leaving };
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
@@ -55,8 +273,25 @@ export default function FilesTab({ project }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const rowClass = (isSelected: boolean) =>
|
||||
`cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`;
|
||||
|
||||
const headerClass = "px-2 py-1.5 font-medium text-[var(--text-secondary)]";
|
||||
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* frequently not announced at all, which is how every completion notice used
|
||||
* to go by in silence.
|
||||
*/
|
||||
const liveText = completed ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
<nav aria-label="Path" className="flex items-center gap-1">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
@@ -64,7 +299,10 @@ export default function FilesTab({ project }: Props) {
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
onClick={() => {
|
||||
wantFocus.current = { key: null };
|
||||
navigate(crumb.path);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||
>
|
||||
{crumb.label}
|
||||
@@ -73,13 +311,38 @@ export default function FilesTab({ project }: Props) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={uploadFile}>Upload file</Button>
|
||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{liveText}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(true);
|
||||
}}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — rename, new folder — goes to
|
||||
`ToastHost` instead, which is above the file viewer's overlay and
|
||||
does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -91,61 +354,218 @@ export default function FilesTab({ project }: Props) {
|
||||
Loading…
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<table role="grid" aria-label="Files" className="w-full text-xs">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col" className={`${headerClass} px-4 text-left`}>
|
||||
Name
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Size
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-left`}>
|
||||
Modified
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={goUp}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
||||
<td colSpan={3} />
|
||||
{creatingFolder && (
|
||||
<tr role="row">
|
||||
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
value={folderDraft}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
onChange={(e) => setFolderDraft(e.target.value)}
|
||||
onBlur={commitFolder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
}
|
||||
}}
|
||||
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
role="row"
|
||||
data-file-row={PARENT_ROW}
|
||||
tabIndex={active === PARENT_ROW ? 0 : -1}
|
||||
aria-label="Parent directory"
|
||||
onClick={() => setActiveRow(PARENT_ROW)}
|
||||
onDoubleClick={openParent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
openParent();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span
|
||||
className={`font-mono ${
|
||||
entry.is_directory
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{entry.is_directory ? "📁 " : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||
{!entry.is_directory && formatBytes(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Download ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
|
||||
<span className="sr-only">Folder, </span>
|
||||
..
|
||||
</td>
|
||||
<td role="gridcell" colSpan={3} />
|
||||
</tr>
|
||||
))}
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSelected = selected === entry.name;
|
||||
const isRenaming = renaming === entry.name;
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
role="row"
|
||||
data-file-row={entry.name}
|
||||
tabIndex={active === entry.name ? 0 : -1}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
}}
|
||||
onDoubleClick={() => openEntry(entry)}
|
||||
onKeyDown={(e) => {
|
||||
if (isRenaming) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
openEntry(entry);
|
||||
} else if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
startRename(entry);
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(entry.name, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className={rowClass(isSelected)}
|
||||
>
|
||||
<td role="gridcell" className="px-4 py-1.5">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label={`New name for ${entry.name}`}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(entry)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`font-mono ${
|
||||
entry.is_directory
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{/* Directory-ness was carried by hue and an
|
||||
`aria-hidden` emoji, i.e. by nothing at all for a
|
||||
screen reader. The emoji stays hidden — it reads
|
||||
as "file folder" in some voices and as nothing in
|
||||
others — and the word is what is announced. */}
|
||||
<span className="sr-only">
|
||||
{entry.is_directory ? "Folder, " : "File, "}
|
||||
</span>
|
||||
{entry.is_directory && <span aria-hidden="true">📁 </span>}
|
||||
<span>{entry.name}</span>
|
||||
{entry.is_symlink && (
|
||||
<span
|
||||
className="ml-1 text-[var(--text-secondary)]"
|
||||
title="Symbolic link"
|
||||
>
|
||||
↗ link
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||
{!entry.is_directory && formatBytes(entry.size)}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
|
||||
{!isRenaming && (
|
||||
<>
|
||||
{/* WCAG 2.5.3: the accessible name has to *contain*
|
||||
the visible label, so the row context is appended
|
||||
rather than substituted. "Rename notes.txt" used
|
||||
to be the whole name, which left a voice-control
|
||||
user saying "click Rename" at a button that had
|
||||
no such name. */}
|
||||
<Button
|
||||
aria-label={`Rename — ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(entry);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPaths.has(entry.path)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<tr role="row">
|
||||
<td
|
||||
role="gridcell"
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
@@ -157,6 +577,14 @@ export default function FilesTab({ project }: Props) {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
entry={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -282,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
const report = await remove(project.id);
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
const verb = leftoverVerb(report);
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
|
||||
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
|
||||
</div>
|
||||
|
||||
{task && (
|
||||
/*
|
||||
An edit is `add` then `remove` (see `update_scheduled_task`), and
|
||||
`triple-c-scheduler`'s remove now reaps the task's log directory —
|
||||
so on a current container the old logs are gone, not merely filed
|
||||
under the old id, which is what this used to promise.
|
||||
|
||||
It is deliberately not stated as a certainty. `/usr/local/bin` only
|
||||
changes on base-image migration or Reset, so a project still running
|
||||
an older base image carries the older scheduler, whose remove leaves
|
||||
the log directory behind. "Assume they go with it" is true in both
|
||||
worlds and spares the user a paragraph about which one they are in.
|
||||
*/
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
The scheduler has no edit command, so saving re-creates this task under a new id and
|
||||
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
|
||||
the old id.
|
||||
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
|
||||
with it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSecretField } from "../../../../hooks/useSecretField";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
@@ -24,14 +25,15 @@ export default function AccessSection({
|
||||
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
|
||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||
// Never seeded from `project` — the backend does not serialize secrets, so
|
||||
// the box is always empty and only an edit may speak about the stored value.
|
||||
const gitToken = useSecretField(project.id);
|
||||
|
||||
useEffect(() => {
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setCaCertPath(project.ca_cert_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
setGitEmail(project.git_user_email ?? "");
|
||||
setGitToken(project.git_token ?? "");
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
@@ -101,15 +103,19 @@ export default function AccessSection({
|
||||
|
||||
<Field
|
||||
label="Git HTTPS token"
|
||||
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container."
|
||||
hint={
|
||||
gitToken.edited
|
||||
? "Saved when you click away. Clearing the box removes the stored token."
|
||||
: "A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container. A stored token is not shown; leave this empty to keep it."
|
||||
}
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => setGitToken(e.target.value)}
|
||||
onBlur={() => save({ git_token: gitToken || null })}
|
||||
value={gitToken.value}
|
||||
onChange={(e) => gitToken.setValue(e.target.value)}
|
||||
onBlur={() => save({ ...gitToken.patch("git_token") })}
|
||||
placeholder="ghp_…"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow";
|
||||
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
|
||||
|
||||
/**
|
||||
* The bridge shipped with a working backend, a typed IPC wrapper, and no way to
|
||||
* reach either: `setAuthBridgeEnabled` had zero call sites, and the
|
||||
* `auth-bridge-changed` event had no listener — so a host port the bridge could
|
||||
* not take was a silent failure that presented as a login that simply hung.
|
||||
* These tests hold both halves down.
|
||||
*/
|
||||
|
||||
const getAuthBridgeStatus = vi.fn<() => Promise<AuthBridgeStatus>>();
|
||||
const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise<AuthBridgeStatus>>();
|
||||
|
||||
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||
getAuthBridgeStatus: () => getAuthBridgeStatus(),
|
||||
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
|
||||
}));
|
||||
|
||||
/** Captured so a test can push an `auth-bridge-changed` payload by hand. */
|
||||
let emit: ((payload: unknown) => void) | null = null;
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => {
|
||||
emit = (payload) => handler({ payload });
|
||||
return () => {
|
||||
emit = null;
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
|
||||
const project = {
|
||||
id: "p1",
|
||||
name: "api",
|
||||
status: "running",
|
||||
auth_bridge_enabled: false,
|
||||
} as unknown as Project;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAuthBridgeStatus.mockResolvedValue(OFF);
|
||||
setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true });
|
||||
});
|
||||
|
||||
describe("AuthBridgeRow", () => {
|
||||
it("turns the bridge on through its own command, not the project save", async () => {
|
||||
// The dedicated command exists so this can be flipped while the container
|
||||
// runs — which is exactly when a user discovers they need it. Routing it
|
||||
// through the Config tab's stopped-only save would make it unreachable at
|
||||
// the only moment it matters.
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays usable while the container is running", async () => {
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("reports a port conflict the poller emitted", async () => {
|
||||
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
emit!({
|
||||
project_id: "p1",
|
||||
status: {
|
||||
enabled: true,
|
||||
active_ports: [],
|
||||
conflicts: [
|
||||
{ port: 54545, reason: "Host port 54545 is already in use (…); not bridged." },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Port 54545/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Port conflict")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores an event for a different project", async () => {
|
||||
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
emit!({
|
||||
project_id: "other",
|
||||
status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] },
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* The two halves of this row disagree about *when*, not about *what*.
|
||||
*
|
||||
* `set_auth_bridge_enabled` resolves with a status sampled as it returned;
|
||||
* the poller's event carries one sampled afterwards. Writing the awaited
|
||||
* value unconditionally therefore rolls the row back in time whenever the
|
||||
* two overlap — the row says "Watching" while a port is bound, which is the
|
||||
* exact silent failure the event subscription was added to end. These two
|
||||
* hold the ordering down from both the resolve and the reject side.
|
||||
*/
|
||||
describe("a pushed event outranks an older awaited result", () => {
|
||||
/** A toggle that will not settle until the test says so. */
|
||||
function deferToggle() {
|
||||
let settle!: (s: AuthBridgeStatus) => void;
|
||||
let fail!: (e: unknown) => void;
|
||||
setAuthBridgeEnabled.mockImplementation(
|
||||
() =>
|
||||
new Promise<AuthBridgeStatus>((resolve, reject) => {
|
||||
settle = resolve;
|
||||
fail = reject;
|
||||
}),
|
||||
);
|
||||
return { settle: (s: AuthBridgeStatus) => settle(s), fail: (e: unknown) => fail(e) };
|
||||
}
|
||||
|
||||
const BRIDGING: AuthBridgeStatus = {
|
||||
enabled: true,
|
||||
active_ports: [{ port: 54545, family: "v4", bridged_at: "", ipv6_warning: null }],
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
async function startToggleThenPush() {
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
|
||||
|
||||
// The poller binds a port while the command is still in flight.
|
||||
emit!({ project_id: "p1", status: BRIDGING });
|
||||
expect(await screen.findByText("Bridging 1 port")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
it("keeps the newer state when the command settles with the older one", async () => {
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
// …and only now returns the snapshot it took *before* that port existed.
|
||||
toggle.settle({ enabled: true, active_ports: [], conflicts: [] });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(),
|
||||
);
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByText("127.0.0.1:54545")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Watching")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let the rollback undo a status pushed while it was failing", async () => {
|
||||
// The command failed, so the error belongs on screen — but the bridge
|
||||
// demonstrably came up, and reverting the switch to off would contradict
|
||||
// the port listed right beside it.
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
toggle.fail("bridge probe timed out");
|
||||
|
||||
expect(await screen.findByText(/probe timed out/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the switch back if the command rejects", async () => {
|
||||
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
|
||||
expect(await screen.findByText(/not found/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridgeIndicator", () => {
|
||||
// Every branch is a glyph plus a word — status is never colour alone.
|
||||
it("says nothing is on when it is off", () => {
|
||||
expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" });
|
||||
});
|
||||
|
||||
it("puts a conflict ahead of everything else", () => {
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
],
|
||||
conflicts: [{ port: 2, reason: "taken" }],
|
||||
},
|
||||
true,
|
||||
).tone,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("flags a port that only took the IPv4 half", () => {
|
||||
// Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener
|
||||
// is a callback that never arrives in front of a bridge reporting healthy.
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" },
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
true,
|
||||
).label,
|
||||
).toBe("IPv4 only");
|
||||
});
|
||||
|
||||
it("counts the ports it is holding", () => {
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
{ port: 2, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
true,
|
||||
).label,
|
||||
).toBe("Bridging 2 ports");
|
||||
});
|
||||
|
||||
it("says it is waiting when the container is not running", () => {
|
||||
// Enabled and holding nothing is normal; enabled with no container is a
|
||||
// different thing, and saying so stops it reading as a failure.
|
||||
expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe(
|
||||
"Waiting for the container",
|
||||
);
|
||||
expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
getAuthBridgeStatus,
|
||||
setAuthBridgeEnabled,
|
||||
} from "../../../../lib/tauri-commands";
|
||||
import type {
|
||||
AuthBridgeChangedEvent,
|
||||
AuthBridgeStatus,
|
||||
Project,
|
||||
} from "../../../../lib/types";
|
||||
import { SwitchRow } from "../../../ui/Field";
|
||||
import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
|
||||
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
|
||||
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
|
||||
|
||||
const LABEL = "Auth bridge";
|
||||
|
||||
/**
|
||||
* What the indicator beside the switch says.
|
||||
*
|
||||
* Split out so the interesting part — that a conflict is a *visible* failure —
|
||||
* can be tested without a container. Every branch pairs a glyph with a word;
|
||||
* none of them are distinguished by colour alone.
|
||||
*/
|
||||
export function bridgeIndicator(
|
||||
status: AuthBridgeStatus | null,
|
||||
containerRunning: boolean,
|
||||
): { tone: StatusTone; label: string } {
|
||||
if (!status) return { tone: "unknown", label: "Checking" };
|
||||
if (!status.enabled) return { tone: "off", label: "Off" };
|
||||
// A conflict means a login is in progress and its port could not be taken —
|
||||
// the one state where doing nothing is the wrong answer, and until now the
|
||||
// one state nothing in the app reported at all.
|
||||
if (status.conflicts.length > 0) {
|
||||
return { tone: "error", label: "Port conflict" };
|
||||
}
|
||||
if (status.active_ports.some((p) => p.ipv6_warning)) {
|
||||
return { tone: "busy", label: "IPv4 only" };
|
||||
}
|
||||
if (status.active_ports.length > 0) {
|
||||
const n = status.active_ports.length;
|
||||
return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` };
|
||||
}
|
||||
// Enabled but holding nothing. Normal: there is only something to bridge
|
||||
// while a login is actually waiting for a callback.
|
||||
if (!containerRunning) {
|
||||
return { tone: "stopped", label: "Waiting for the container" };
|
||||
}
|
||||
return { tone: "ok", label: "Watching" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for `auth_bridge_enabled`, and the only place it can be changed.
|
||||
*
|
||||
* Two things here are deliberate and easy to undo by accident:
|
||||
*
|
||||
* - **It does not go through the Config tab's `save`.** That path is gated on
|
||||
* a stopped container, because almost everything else in the tab is baked
|
||||
* into the container at creation. This is not: the bridge is entirely
|
||||
* host-side, and `set_auth_bridge_enabled` exists precisely so it can be
|
||||
* flipped *while a login is hanging*, which is when the user finds out they
|
||||
* need it. Routing it through the generic save would make it unreachable at
|
||||
* the only moment it matters.
|
||||
* - **It subscribes to `auth-bridge-changed`.** The poller already emits the
|
||||
* bridged-port and conflict sets on every change and, before this, nothing
|
||||
* listened — so a host port the bridge could not take was a completely
|
||||
* silent failure, indistinguishable from a login that simply hung.
|
||||
*/
|
||||
export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
const projectId = project.id;
|
||||
const containerRunning = project.status === "running";
|
||||
|
||||
const [status, setStatus] = useState<AuthBridgeStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Which write to `status` is the newest — the same "is this still mine?"
|
||||
* guard `useContainerMigration` uses around its async
|
||||
* writes, and needed here for a reason that is easy to miss.
|
||||
*
|
||||
* There are two sources of truth for this row and only one of them is
|
||||
* ordered. `set_auth_bridge_enabled` resolves with a status *sampled at the
|
||||
* moment it returned*; the poller's `auth-bridge-changed` event carries one
|
||||
* sampled later. Awaiting the command therefore hands back a value that may
|
||||
* already be historical, and writing it unconditionally is how the row ends
|
||||
* up saying "Watching" while a port is in fact bound — the failure mode the
|
||||
* event subscription exists to prevent, reintroduced one line below it.
|
||||
*
|
||||
* So every write claims a generation and only lands if it still holds it.
|
||||
* A pushed event always claims a fresh one, which is what makes it win over
|
||||
* an older awaited result no matter which order the two arrive in.
|
||||
*/
|
||||
const generation = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++generation.current;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
getAuthBridgeStatus(projectId)
|
||||
.then((s) => {
|
||||
// The initial fetch races the poller exactly like the toggle does: an
|
||||
// event can land first and describe a bridge this reply predates.
|
||||
if (!cancelled && generation.current === mine) setStatus(s);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||
if (event.payload.project_id !== projectId) return;
|
||||
// A pushed status is the most recent observation that exists, so it
|
||||
// claims the newest generation and invalidates anything still in flight.
|
||||
generation.current += 1;
|
||||
setStatus(event.payload.status);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})
|
||||
.catch((e) => console.error("Auth bridge event subscription failed:", e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (next: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Optimistic, so the switch responds even though enabling has to await a
|
||||
// container probe. It claims a generation like every other write, so a
|
||||
// pushed event that lands mid-flight supersedes it rather than being
|
||||
// undone by the settle below.
|
||||
const mine = ++generation.current;
|
||||
setStatus((s) => (s ? { ...s, enabled: next } : s));
|
||||
try {
|
||||
const settled = await setAuthBridgeEnabled(projectId, next);
|
||||
// Stale by the time it arrived: the poller has already told us
|
||||
// something newer, and `settled` predates it.
|
||||
if (generation.current !== mine) return;
|
||||
setStatus(settled);
|
||||
} catch (e) {
|
||||
// The error is reported either way — the command really did fail — but
|
||||
// the rollback must not resurrect the pre-toggle value over a status
|
||||
// the poller pushed while the command was failing.
|
||||
setError(String(e));
|
||||
if (generation.current !== mine) return;
|
||||
setStatus((s) => (s ? { ...s, enabled: !next } : s));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Fall back to the persisted flag until the first status arrives, so the
|
||||
// switch never renders in the wrong position.
|
||||
const enabled = status?.enabled ?? project.auth_bridge_enabled;
|
||||
const indicator = bridgeIndicator(status, containerRunning);
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
label={LABEL}
|
||||
hint={
|
||||
<>
|
||||
Mirrors a port a program inside the container is listening on onto the
|
||||
host's <code>127.0.0.1</code>, so a browser OAuth callback can reach
|
||||
the listener waiting inside the container —{" "}
|
||||
<code>claude login</code>, <code>aws sso login</code> and{" "}
|
||||
<code>gh auth login</code> all work this way, and without it the
|
||||
browser calls back into nothing and the login hangs. Host-side only:
|
||||
it never recreates the container, and it can be switched on while one
|
||||
is running. A bridged port is unauthenticated and reachable by any
|
||||
local process for as long as the in-container listener exists, so
|
||||
leave it off unless you need it.
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<StatusIndicator tone={indicator.tone} label={indicator.label} />
|
||||
{status?.active_ports.map((p) => (
|
||||
<span
|
||||
key={p.port}
|
||||
className="font-mono text-[var(--text-secondary)]"
|
||||
title={p.ipv6_warning ?? `Bound on host 127.0.0.1:${p.port}`}
|
||||
>
|
||||
127.0.0.1:{p.port}
|
||||
{p.ipv6_warning ? " (IPv4 only)" : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{status?.conflicts.map((c) => (
|
||||
<span
|
||||
key={c.port}
|
||||
className="mt-1 block text-[var(--error)]"
|
||||
role="status"
|
||||
>
|
||||
Port {c.port}: {c.reason}
|
||||
</span>
|
||||
))}
|
||||
{status?.active_ports
|
||||
.filter((p) => p.ipv6_warning)
|
||||
.map((p) => (
|
||||
<span key={p.port} className="mt-1 block text-[var(--warning)]">
|
||||
Port {p.port}: {p.ipv6_warning}
|
||||
</span>
|
||||
))}
|
||||
{error && (
|
||||
<span className="mt-1 block text-[var(--error)]">{error}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<Toggle
|
||||
label={LABEL}
|
||||
checked={enabled}
|
||||
// Never gated on the container being stopped — see the note above.
|
||||
disabled={busy}
|
||||
onChange={toggle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSecretField, withoutUntouchedSecrets } from "../../../../hooks/useSecretField";
|
||||
import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
@@ -16,6 +17,17 @@ import Field, {
|
||||
} from "../../../ui/Field";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
|
||||
/** Bedrock fields held in the OS keychain, never serialized back to us. */
|
||||
const BEDROCK_SECRET_KEYS = [
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
"aws_bearer_token",
|
||||
] as const;
|
||||
|
||||
/** The same, for the OpenAI-compatible backend. */
|
||||
const OPENAI_SECRET_KEYS = ["api_key"] as const;
|
||||
|
||||
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||
auth_method: "static_credentials",
|
||||
aws_region: "us-east-1",
|
||||
@@ -65,11 +77,12 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
|
||||
// Local text state — saved on blur, not on every keystroke.
|
||||
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
||||
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
||||
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
||||
// Secrets are never seeded from `project` — see `useSecretField`.
|
||||
const accessKeyId = useSecretField(project.id);
|
||||
const secretKey = useSecretField(project.id);
|
||||
const sessionToken = useSecretField(project.id);
|
||||
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
||||
const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? "");
|
||||
const bearerToken = useSecretField(project.id);
|
||||
const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
||||
|
||||
@@ -97,9 +110,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
const [oaiApiKey, setOaiApiKey] = useState(
|
||||
project.openai_compatible_config?.api_key ?? "",
|
||||
);
|
||||
const oaiApiKey = useSecretField(project.id);
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
@@ -107,14 +118,13 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
// Secret fields are deliberately absent here: `useSecretField` owns its own
|
||||
// reset, and re-seeding one from `project` would write an empty string over
|
||||
// whatever the user had half-typed on any unrelated project update.
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
setBedrockRegion(bc.aws_region);
|
||||
setAccessKeyId(bc.aws_access_key_id ?? "");
|
||||
setSecretKey(bc.aws_secret_access_key ?? "");
|
||||
setSessionToken(bc.aws_session_token ?? "");
|
||||
setProfile(bc.aws_profile ?? "");
|
||||
setBearerToken(bc.aws_bearer_token ?? "");
|
||||
setBedrockModelId(bc.model_id ?? "");
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
@@ -129,13 +139,18 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||
save({
|
||||
bedrock_config: withoutUntouchedSecrets(
|
||||
{ ...bedrock, ...patch },
|
||||
patch,
|
||||
BEDROCK_SECRET_KEYS,
|
||||
),
|
||||
});
|
||||
|
||||
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||
save({
|
||||
@@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
openai_compatible_config: withoutUntouchedSecrets(
|
||||
{
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
patch,
|
||||
OPENAI_SECRET_KEYS,
|
||||
),
|
||||
});
|
||||
|
||||
// Defaults to on: projects created before the field existed, and any data
|
||||
@@ -261,9 +280,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
||||
value={accessKeyId.value}
|
||||
onChange={(e) => accessKeyId.setValue(e.target.value)}
|
||||
onBlur={() => saveBedrock(accessKeyId.patch("aws_access_key_id"))}
|
||||
placeholder="AKIA…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -278,10 +297,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
value={secretKey.value}
|
||||
onChange={(e) => secretKey.setValue(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_secret_access_key: secretKey || null })
|
||||
saveBedrock(secretKey.patch("aws_secret_access_key"))
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -296,10 +315,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(e) => setSessionToken(e.target.value)}
|
||||
value={sessionToken.value}
|
||||
onChange={(e) => sessionToken.setValue(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_session_token: sessionToken || null })
|
||||
saveBedrock(sessionToken.patch("aws_session_token"))
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -337,9 +356,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
||||
value={bearerToken.value}
|
||||
onChange={(e) => bearerToken.setValue(e.target.value)}
|
||||
onBlur={() => saveBedrock(bearerToken.patch("aws_bearer_token"))}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
@@ -507,9 +526,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={oaiApiKey}
|
||||
onChange={(e) => setOaiApiKey(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
||||
value={oaiApiKey.value}
|
||||
onChange={(e) => oaiApiKey.setValue(e.target.value)}
|
||||
onBlur={() => saveOpenAi(oaiApiKey.patch("api_key"))}
|
||||
placeholder="sk-…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import RuntimeSection from "./RuntimeSection";
|
||||
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
|
||||
|
||||
// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it
|
||||
// does not go through `save`.
|
||||
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true }));
|
||||
|
||||
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||
getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE),
|
||||
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const VPN = "VPN support";
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<RuntimeSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
disabledReason="Container must be stopped to change this setting."
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RuntimeSection — VPN support toggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("saves only the VPN flag when switched on", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: true });
|
||||
});
|
||||
|
||||
it("saves the flag off again, rather than dropping the key", () => {
|
||||
// Off has to be written explicitly: the container carries a
|
||||
// `triple-c.vpn-support` label either way, and an absent value would leave
|
||||
// the capability granted.
|
||||
renderSection({ vpn_support_enabled: true });
|
||||
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: false });
|
||||
});
|
||||
|
||||
it("reflects the project's current state", () => {
|
||||
renderSection({ vpn_support_enabled: true });
|
||||
expect(screen.getByRole("switch", { name: VPN })).toBeChecked();
|
||||
});
|
||||
|
||||
it("cannot be changed while the container is running", () => {
|
||||
// Capabilities and devices are fixed at creation, so this setting is gated
|
||||
// on the container being stopped along with the rest of the tab.
|
||||
renderSection({}, true);
|
||||
const toggle = screen.getByRole("switch", { name: VPN });
|
||||
expect(toggle).toBeDisabled();
|
||||
fireEvent.click(toggle);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns that the change recreates the container", () => {
|
||||
renderSection();
|
||||
expect(
|
||||
screen.getByText(/recreates the container on its next start/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `scope="project"` on the settings editor is one prop with no visible owner,
|
||||
* and deleting it fails silently in the worst possible direction: the editor
|
||||
* falls back to `"global"`, every three-state control collapses to an on/off
|
||||
* switch, and a field the project is *inheriting* as on renders flat Off. The
|
||||
* user then reads a lie and, worse, flipping that switch writes a deliberate
|
||||
* `false` that overrides the global On they thought they were looking at.
|
||||
*
|
||||
* Nothing asserted the prop was passed, so these go through what is rendered
|
||||
* rather than through props — a switch where a select belongs is exactly the
|
||||
* regression, and it is visible from the outside.
|
||||
*/
|
||||
describe("RuntimeSection — Claude Code settings are edited at project scope", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("gives every setting the third Global state a project can inherit through", () => {
|
||||
renderSection();
|
||||
const focus = screen.getByLabelText("Focus mode") as HTMLSelectElement;
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
});
|
||||
|
||||
it("renders an untouched setting as inheriting, not as Off", () => {
|
||||
// `claude_code_settings: null` means "this project has no opinion", which
|
||||
// is not the same instruction as off. At global scope the same field is a
|
||||
// plain unchecked switch — indistinguishable from a user who turned it
|
||||
// off, and the reason the missing prop would never be noticed.
|
||||
renderSection({ claude_code_settings: null });
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe(
|
||||
"global",
|
||||
);
|
||||
expect(screen.queryByRole("switch", { name: "Focus mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stored project override visible over the inherited value", () => {
|
||||
renderSection({
|
||||
claude_code_settings: {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: true,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
},
|
||||
});
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeSection — auth bridge toggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("is reachable while the container is running", async () => {
|
||||
// The rest of the tab is gated on a stopped container because those
|
||||
// settings are baked in at creation. This one is host-side and has its own
|
||||
// command, and the moment a user needs it is the moment a login is hanging
|
||||
// in a *running* container — so the tab's `disabled` must not reach it.
|
||||
renderSection({ status: "running" }, true);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Auth bridge" });
|
||||
await waitFor(() => expect(toggle).not.toBeDisabled());
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
|
||||
// And never through the generic project save, which would drop it on the
|
||||
// floor while the container runs.
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field";
|
||||
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||
import AuthBridgeRow from "./AuthBridgeRow";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -59,7 +60,7 @@ 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, Tailscale) 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."
|
||||
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"
|
||||
@@ -70,6 +71,12 @@ export default function RuntimeSection({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Not gated on `disabled`: the bridge is host-side and has its own
|
||||
command, so it can be switched on while a login is hanging — which
|
||||
is the only moment anyone reaches for it. It owns its state rather
|
||||
than going through `save`. */}
|
||||
<AuthBridgeRow project={project} />
|
||||
|
||||
<SwitchRow
|
||||
label="Mission Control"
|
||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||
@@ -102,9 +109,19 @@ export default function RuntimeSection({
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||
description={
|
||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||
"also needs the base image updated first: those four are cleared by removing a " +
|
||||
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||
}
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope="project"
|
||||
settings={project.claude_code_settings}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import WorkspaceSection from "./WorkspaceSection";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
|
||||
// The Browse button is the OS folder picker.
|
||||
const open = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: (...args: unknown[]) => open(...args),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<WorkspaceSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every folder list this component has sent to `update_project`. */
|
||||
function savedLists() {
|
||||
return save.mock.calls
|
||||
.filter(([patch]) => "paths" in patch)
|
||||
.map(([patch]) => patch.paths);
|
||||
}
|
||||
|
||||
describe("WorkspaceSection — the blank row is never stored", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* The bug this file exists for. `create_container` mounts every stored row
|
||||
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
|
||||
* container with `field Source must not be empty` — the project can never be
|
||||
* started or recreated again. Click "+ Add folder", blur a field, and it is
|
||||
* bricked.
|
||||
*/
|
||||
it("drops the placeholder row when a real edit is saved", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when Browse fills a different row in", async () => {
|
||||
open.mockResolvedValueOnce("/src/api-v2");
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
// The picker is awaited inside the handler, so the state update that
|
||||
// follows it lands outside the click.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
|
||||
});
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when a row is removed", () => {
|
||||
renderSection({
|
||||
paths: [
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("never sends a row with an empty host path, whatever the route", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
for (const list of savedLists()) {
|
||||
for (const row of list) {
|
||||
expect(row.host_path).not.toBe("");
|
||||
expect(row.mount_name).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceSection — what a blur is allowed to save", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty — which `update_project` refuses,
|
||||
* turning an ordinary keystroke into an error toast.
|
||||
*/
|
||||
it("holds a half-filled row back until it is complete", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const newHostPath = screen.getByLabelText("Folder 2 host path");
|
||||
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
|
||||
fireEvent.blur(newHostPath);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
const newMountName = screen.getByLabelText("Folder 2 mount name");
|
||||
fireEvent.change(newMountName, { target: { value: "web" } });
|
||||
fireEvent.blur(newMountName);
|
||||
expect(savedLists()[0]).toEqual([
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blurring out of an untouched field is not an edit. Saving anyway would
|
||||
* round-trip the filtered list through `project` and take the empty row away
|
||||
* while the user was still filling it in.
|
||||
*/
|
||||
it("saves nothing when the blur changed nothing", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still saves a rename, which does not go through the folder list", () => {
|
||||
renderSection();
|
||||
const name = screen.getByDisplayValue("api-server");
|
||||
fireEvent.change(name, { target: { value: "api-v2" } });
|
||||
fireEvent.blur(name);
|
||||
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Whether two folder lists are the same rows in the same order. */
|
||||
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
@@ -19,6 +27,49 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
/**
|
||||
* Persist a folder list, minus the rows that are only in it because the UI
|
||||
* put them there.
|
||||
*
|
||||
* **The blank row must never reach the store.** "+ Add folder" inserts
|
||||
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
|
||||
* mounts every stored row unfiltered — a stored blank one becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
|
||||
* `field Source must not be empty`. The project then cannot be started or
|
||||
* recreated at all, from a click and a blur. `AddProjectDialog` has always
|
||||
* filtered this; this section computed the filtered list and then saved the
|
||||
* unfiltered one.
|
||||
*
|
||||
* Every save goes through here for that reason — Browse and Remove write the
|
||||
* list too, and either can be holding a blank row from an earlier click.
|
||||
*/
|
||||
const persist = (rows: ProjectPath[]) => {
|
||||
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
return save({ paths: filled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Save only when every row is fully filled in.
|
||||
*
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty. `update_project` now validates —
|
||||
* a half-filled row is refused — so the unconditional save turned an ordinary
|
||||
* keystroke into an error toast. A blank row is *not* incomplete: the
|
||||
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
||||
*
|
||||
* A blur that changed nothing saves nothing, which is what keeps the blank
|
||||
* row on screen while it is being filled in: persisting the filtered list
|
||||
* would round-trip through `project` and take the empty row away under the
|
||||
* cursor.
|
||||
*/
|
||||
const saveIfComplete = () => {
|
||||
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
||||
if (halfFilled) return;
|
||||
if (sameRows(filled, project.paths ?? [])) return;
|
||||
return persist(paths);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Workspace"
|
||||
@@ -70,7 +121,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
onBlur={() => saveIfComplete()}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-0 ${inputClass}`}
|
||||
@@ -90,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
mount_name: updated[i].mount_name || basename,
|
||||
};
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -107,7 +158,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
onBlur={() => saveIfComplete()}
|
||||
placeholder="name"
|
||||
disabled={disabled}
|
||||
className={`w-40 ${monoInputClass}`}
|
||||
@@ -121,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
IMAGE_PREVIEW_LIMIT,
|
||||
TEXT_PREVIEW_LIMIT,
|
||||
decodeBase64,
|
||||
extensionOf,
|
||||
imageMimeFor,
|
||||
looksBinary,
|
||||
previewKind,
|
||||
previewLimit,
|
||||
} from "./filePreview";
|
||||
|
||||
describe("extensionOf", () => {
|
||||
it("lowercases, and takes only the last segment", () => {
|
||||
expect(extensionOf("Photo.PNG")).toBe("png");
|
||||
expect(extensionOf("archive.tar.gz")).toBe("gz");
|
||||
expect(extensionOf("/workspace/app/main.rs")).toBe("rs");
|
||||
});
|
||||
|
||||
it("treats a leading dot as hidden, not as an extension", () => {
|
||||
// `.gitignore` is a text file called `.gitignore`, not one of type "gitignore".
|
||||
expect(extensionOf(".gitignore")).toBe("");
|
||||
expect(extensionOf("Makefile")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewKind", () => {
|
||||
it("recognises images by extension, with a MIME the Blob can use", () => {
|
||||
expect(previewKind("logo.png")).toBe("image");
|
||||
expect(imageMimeFor("logo.PNG")).toBe("image/png");
|
||||
expect(imageMimeFor("photo.jpeg")).toBe("image/jpeg");
|
||||
expect(imageMimeFor("icon.svg")).toBe("image/svg+xml");
|
||||
expect(imageMimeFor("notes.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("recognises known text extensions and conventional extensionless names", () => {
|
||||
expect(previewKind("main.rs")).toBe("text");
|
||||
expect(previewKind("config.yaml")).toBe("text");
|
||||
expect(previewKind("Dockerfile")).toBe("text");
|
||||
expect(previewKind("README")).toBe("text");
|
||||
expect(previewKind(".gitignore")).toBe("text");
|
||||
});
|
||||
|
||||
it("leaves anything else undecided rather than refusing it outright", () => {
|
||||
// `unknown` means "read it and sniff the bytes" — a .bak of a config file
|
||||
// should still preview.
|
||||
expect(previewKind("dump.bak")).toBe("unknown");
|
||||
expect(previewKind("app.wasm")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewLimit", () => {
|
||||
it("gives images the bigger budget, since they are what blows a text cap", () => {
|
||||
expect(previewLimit("photo.jpg")).toBe(IMAGE_PREVIEW_LIMIT);
|
||||
expect(previewLimit("notes.md")).toBe(TEXT_PREVIEW_LIMIT);
|
||||
expect(previewLimit("mystery.bin")).toBe(TEXT_PREVIEW_LIMIT);
|
||||
expect(IMAGE_PREVIEW_LIMIT).toBeGreaterThan(TEXT_PREVIEW_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64 / looksBinary", () => {
|
||||
it("round-trips bytes that are not valid UTF-8", () => {
|
||||
// The reason the backend returns base64 at all: these bytes must survive.
|
||||
const bytes = decodeBase64(btoa("\xff\xd8\xff\xe0"));
|
||||
expect(Array.from(bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
|
||||
});
|
||||
|
||||
it("calls a NUL-bearing prefix binary and plain text text", () => {
|
||||
expect(looksBinary(new Uint8Array([0x68, 0x69, 0x0a]))).toBe(false);
|
||||
expect(looksBinary(new Uint8Array([0x68, 0x00, 0x69]))).toBe(true);
|
||||
});
|
||||
|
||||
it("only sniffs the first 8 KB, so a NUL deep in a big file is ignored", () => {
|
||||
const bytes = new Uint8Array(20000).fill(0x61);
|
||||
bytes[9000] = 0;
|
||||
expect(looksBinary(bytes)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* What the Files viewer can show, and how much of it to ask for.
|
||||
*
|
||||
* Pure helpers, deliberately separate from the modal: the type sniffing is
|
||||
* where a preview quietly turns into a screenful of mojibake, and it is worth
|
||||
* testing without a container.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extension → MIME, for the raster/vector types an `<img>` actually renders.
|
||||
* The MIME matters because the bytes are handed to the DOM as a `Blob`, and a
|
||||
* blob with the wrong (or empty) type will not decode.
|
||||
*/
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
avif: "image/avif",
|
||||
// Safe in an `<img>`: that context cannot run the script an SVG may carry.
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
/** Extensions we are confident are text, so no byte sniffing is needed. */
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "rst", "log", "csv", "tsv",
|
||||
"json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
|
||||
"js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
|
||||
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "pl", "r",
|
||||
"sh", "bash", "zsh", "fish", "ps1", "bat",
|
||||
"html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less",
|
||||
"sql", "graphql", "gql", "proto", "diff", "patch", "lock", "gitignore",
|
||||
"dockerfile", "makefile", "cmake", "gradle", "tf", "tfvars",
|
||||
]);
|
||||
|
||||
/** Extensionless files that are text by convention. */
|
||||
const TEXT_BASENAMES = new Set([
|
||||
"dockerfile", "makefile", "readme", "license", "licence", "changelog",
|
||||
"authors", "notice", "copying", "procfile", "rakefile", "gemfile", "vagrantfile",
|
||||
// Dotfiles: the leading dot is stripped before the lookup.
|
||||
"gitignore", "gitattributes", "gitmodules", "dockerignore", "npmrc", "nvmrc",
|
||||
"editorconfig", "bashrc", "zshrc", "profile", "env",
|
||||
]);
|
||||
|
||||
/** 1 MiB of text is already far more than anyone reads in a modal. */
|
||||
export const TEXT_PREVIEW_LIMIT = 1024 * 1024;
|
||||
/**
|
||||
* Images get five times the budget: they are the file kind that routinely
|
||||
* blows past a text-sized cap, and a half-read image is not a preview at all —
|
||||
* it either decodes whole or it does not.
|
||||
*/
|
||||
export const IMAGE_PREVIEW_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
/** Lowercased extension, or "" for an extensionless name. */
|
||||
export function extensionOf(name: string): string {
|
||||
const base = name.slice(name.lastIndexOf("/") + 1);
|
||||
const dot = base.lastIndexOf(".");
|
||||
// A leading dot is "hidden file", not "extension" (`.gitignore`).
|
||||
if (dot <= 0) return "";
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** The MIME to build the Blob with, or null if this is not a previewable image. */
|
||||
export function imageMimeFor(name: string): string | null {
|
||||
return IMAGE_MIME[extensionOf(name)] ?? null;
|
||||
}
|
||||
|
||||
export type PreviewKind = "image" | "text" | "unknown";
|
||||
|
||||
/**
|
||||
* A first guess from the name alone. `unknown` is not a refusal — the viewer
|
||||
* reads the bytes and falls back to sniffing them, so a `.bak` of a config
|
||||
* file still previews.
|
||||
*/
|
||||
export function previewKind(name: string): PreviewKind {
|
||||
if (imageMimeFor(name)) return "image";
|
||||
const ext = extensionOf(name);
|
||||
if (ext) return TEXT_EXTENSIONS.has(ext) ? "text" : "unknown";
|
||||
const base = name.slice(name.lastIndexOf("/") + 1).replace(/^\./, "").toLowerCase();
|
||||
return TEXT_BASENAMES.has(base) ? "text" : "unknown";
|
||||
}
|
||||
|
||||
/** How many bytes to ask the backend for, given what we expect to render. */
|
||||
export function previewLimit(name: string): number {
|
||||
return previewKind(name) === "image" ? IMAGE_PREVIEW_LIMIT : TEXT_PREVIEW_LIMIT;
|
||||
}
|
||||
|
||||
/** Base64 → bytes. `atob` yields a binary string; widen it one char at a time. */
|
||||
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
|
||||
* and it is what `git` and `grep` use to decide the same question.
|
||||
*/
|
||||
export function looksBinary(bytes: Uint8Array): boolean {
|
||||
const limit = Math.min(bytes.length, 8000);
|
||||
for (let i = 0; i < limit; i++) if (bytes[i] === 0) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
/** Shared formatting helpers for the Project Home views. */
|
||||
|
||||
import { formatBytes as shared } from "../../../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels.
|
||||
*
|
||||
* Kept as a named re-export rather than deleted: three modules import it from
|
||||
* here, and the binary/decimal-label pairing is a Project Home convention
|
||||
* rather than the app-wide default. The implementation is `lib/formatBytes`.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
return shared(bytes, { binary: true });
|
||||
}
|
||||
|
||||
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeLeftovers, leftoverVerb } from "./removalReport";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
function report(overrides: Partial<ProjectRemovalReport> = {}): ProjectRemovalReport {
|
||||
return {
|
||||
container: null,
|
||||
image: null,
|
||||
volumes: [],
|
||||
retry_scheduled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectRemovalIsClean", () => {
|
||||
it("is true only when nothing survived", () => {
|
||||
expect(projectRemovalIsClean(report())).toBe(true);
|
||||
expect(projectRemovalIsClean(report({ container: "triple-c-abc" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ image: "triple-c-snapshot-abc:latest" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ volumes: ["triple-c-home-abc"] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeLeftovers", () => {
|
||||
it("names each kind of leftover", () => {
|
||||
expect(describeLeftovers(report({ container: "triple-c-abc" }))).toBe("its container");
|
||||
expect(describeLeftovers(report({ image: "x" }))).toBe("its saved image");
|
||||
expect(describeLeftovers(report({ volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeLeftovers(report({ volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
});
|
||||
|
||||
it("joins multiple kinds together", () => {
|
||||
expect(
|
||||
describeLeftovers(report({ container: "triple-c-abc", image: "x", volumes: ["v1", "v2"] })),
|
||||
).toBe("its container, its saved image, 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leftoverVerb", () => {
|
||||
it("is singular for exactly one leftover of any kind", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ image: "x" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ volumes: ["v1"] }))).toBe("was");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived, including multiple volumes alone", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc", image: "x" }))).toBe("were");
|
||||
expect(leftoverVerb(report({ volumes: ["v1", "v2"] }))).toBe("were");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
/**
|
||||
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
|
||||
*
|
||||
* Worded as "could not confirm" rather than "is still on disk": the same
|
||||
* report shape covers a genuine leftover (a locked volume) and a daemon that
|
||||
* was simply unreachable at the time, in which case nothing was ever created
|
||||
* and there is nothing to find — asserting certainty either way would be
|
||||
* wrong in one of those cases.
|
||||
*/
|
||||
export function describeLeftovers(report: ProjectRemovalReport): string {
|
||||
const parts: string[] = [];
|
||||
if (report.container) parts.push("its container");
|
||||
if (report.image) parts.push("its saved image");
|
||||
if (report.volumes.length === 1) parts.push("a volume");
|
||||
else if (report.volumes.length > 1) parts.push(`${report.volumes.length} volumes`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** How many distinct things `describeLeftovers` is describing — a container
|
||||
* and an image each count as one, however many volumes are named. Shared by
|
||||
* `leftoverVerb` and `leftoverPronoun` so the two can never disagree about
|
||||
* singular vs. plural. */
|
||||
function leftoverCount(report: ProjectRemovalReport): number {
|
||||
return (report.container ? 1 : 0) + (report.image ? 1 : 0) + report.volumes.length;
|
||||
}
|
||||
|
||||
/** Verb agreement for `describeLeftovers`'s output — "its container" needs
|
||||
* "was", "its container, a volume" needs "were". */
|
||||
export function leftoverVerb(report: ProjectRemovalReport): "was" | "were" {
|
||||
return leftoverCount(report) === 1 ? "was" : "were";
|
||||
}
|
||||
|
||||
/** Pronoun agreement for referring back to `describeLeftovers`'s output —
|
||||
* "remove it manually" for one thing, "remove them manually" for more. */
|
||||
export function leftoverPronoun(report: ProjectRemovalReport): "it" | "them" {
|
||||
return leftoverCount(report) === 1 ? "it" : "them";
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { PackageFailure } from "../../lib/types";
|
||||
import { formatBytes } from "../../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||
@@ -62,14 +63,7 @@ export const REPLAY_COST =
|
||||
|
||||
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
|
||||
export function formatDataSize(bytes: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||
|
||||
@@ -6,14 +6,38 @@ import type { ClearTokenOutcome, Project } from "../../lib/types";
|
||||
|
||||
const hasClaudeToken = vi.fn();
|
||||
const clearClaudeToken = vi.fn();
|
||||
const sweepClaudeTokenSnapshots = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
hasClaudeToken: () => hasClaudeToken(),
|
||||
clearClaudeToken: () => clearClaudeToken(),
|
||||
sweepClaudeTokenSnapshots: () => sweepClaudeTokenSnapshots(),
|
||||
acquireClaudeToken: vi.fn(),
|
||||
submitClaudeTokenCode: vi.fn(),
|
||||
}));
|
||||
|
||||
// Stood in for so a sign-in can be *completed* in a test. The panel's reaction
|
||||
// to `onAuthenticated` is the subject of the H1 sequence below, and driving the
|
||||
// real acquisition dialog to get there would test the dialog instead.
|
||||
vi.mock("./ClaudeAuthModal", () => ({
|
||||
default: ({
|
||||
onAuthenticated,
|
||||
onClose,
|
||||
}: {
|
||||
onAuthenticated: () => void;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
finish sign-in
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
|
||||
|
||||
@@ -59,11 +83,22 @@ const running = (over: Partial<Project> = {}): Project => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A `ClearTokenOutcome` with nothing left behind, plus any overrides. */
|
||||
const outcome = (over: Partial<ClearTokenOutcome> = {}): ClearTokenOutcome => ({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: [],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("SharedAuthSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
projects = [];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
sweepClaudeTokenSnapshots.mockResolvedValue(outcome());
|
||||
useAppState.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
@@ -189,4 +224,219 @@ describe("SharedAuthSettings", () => {
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
|
||||
});
|
||||
|
||||
// ── A skipped scrub is not a success, and must stay retryable ────────────
|
||||
// `scrub_secrets_from_snapshots` refuses a project another operation holds
|
||||
// rather than racing its `:latest` tag. That leaves a live ~1-year token in
|
||||
// the image, so it can be neither folded into the success message nor
|
||||
// described as a permanent failure whose remedy is Reset.
|
||||
|
||||
it("reports a skipped snapshot as an incomplete revocation, not a success", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_skipped: [
|
||||
"triple-c-snapshot-p1:latest: This project's container is being started or recreated. Wait for it to finish before removing a credential from its snapshot.",
|
||||
],
|
||||
});
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.message).toMatch(/still in 1 snapshot image/i);
|
||||
expect(toast.detail).toMatch(/run the\s+cleanup again/i);
|
||||
// The wrong advice for a transient refusal.
|
||||
expect(toast.detail).not.toMatch(/Reset/i);
|
||||
});
|
||||
|
||||
it("keeps a retry available after the revoke has cleared the keychain", async () => {
|
||||
projects = [running()];
|
||||
// Stored when the panel mounts, gone after the revoke — which is exactly
|
||||
// the state that used to remove the only button able to finish the job.
|
||||
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValue({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
|
||||
const retry = await screen.findByTestId("shared-auth-retry");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("shared-auth-leftover")).toHaveTextContent(
|
||||
/still readable/i,
|
||||
);
|
||||
expect(retry).toBeEnabled();
|
||||
});
|
||||
|
||||
it("clears the warning when a retry finally finishes the job", async () => {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValueOnce({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
const retry = await screen.findByTestId("shared-auth-retry");
|
||||
|
||||
sweepClaudeTokenSnapshots.mockResolvedValueOnce(
|
||||
outcome({ snapshots_scrubbed: ["triple-c-snapshot-p1:latest"] }),
|
||||
);
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument(),
|
||||
);
|
||||
// One revoke, one sweep — the retry must not be a second revoke.
|
||||
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||
expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(1);
|
||||
const toast = useAppState.getState().toasts.at(-1)!;
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toMatch(/cleared from 1 snapshot image/i);
|
||||
});
|
||||
|
||||
it("offers a snapshot sweep even when no token is stored", async () => {
|
||||
// Snapshots committed by an older build carry the token whether or not
|
||||
// anything is in the keychain today, so the sweep cannot be gated on it.
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
const sweep = await screen.findByTestId("shared-auth-sweep");
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
fireEvent.click(sweep);
|
||||
|
||||
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||
expect(clearClaudeToken).not.toHaveBeenCalled();
|
||||
const toast = useAppState.getState().toasts.at(-1)!;
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("No snapshot image is holding the token.");
|
||||
});
|
||||
|
||||
it("tolerates a backend that does not report skipped snapshots", async () => {
|
||||
// `snapshots_skipped` is newer than the rest of the payload; its absence
|
||||
// must read as "none", never as undefined reaching the UI.
|
||||
const toast = await revoke({ snapshots_scrubbed: ["triple-c-snapshot-p1:latest"] });
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── The retry must not be a revoke in disguise ───────────────────────────
|
||||
// The sequence that shipped green: revoke, get a skipped image, re-authenticate
|
||||
// from the button directly above the warning, then press the retry the warning
|
||||
// is still offering. One command was behind both, so that press deleted the
|
||||
// token acquired seconds earlier — no confirmation, and a toast that mentioned
|
||||
// only images. The deliberate Revoke needs a modal; this needed nothing.
|
||||
|
||||
it("does not leave a stale cleanup panel over a freshly acquired token", async () => {
|
||||
projects = [running()];
|
||||
// Stored when the panel mounts, gone after the revoke, stored again once
|
||||
// the sign-in finishes.
|
||||
hasClaudeToken
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
clearClaudeToken.mockResolvedValue(
|
||||
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||
);
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
await screen.findByTestId("shared-auth-leftover");
|
||||
|
||||
// Re-authenticate from the button above the warning.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Authenticate" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "finish sign-in" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Revoke" });
|
||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("shared-auth-retry")).not.toBeInTheDocument();
|
||||
|
||||
// The images can still be cleaned up — and doing so does not spend the new
|
||||
// token.
|
||||
fireEvent.click(screen.getByTestId("shared-auth-sweep"));
|
||||
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends the retry to the sweep-only command, never to the revoke", async () => {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValue(
|
||||
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||
);
|
||||
sweepClaudeTokenSnapshots.mockResolvedValue(
|
||||
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||
);
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
const retry = await screen.findByTestId("shared-auth-retry");
|
||||
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(await screen.findByTestId("shared-auth-retry"));
|
||||
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(2));
|
||||
|
||||
// However many times it is pressed, the keychain is touched exactly once —
|
||||
// by the revoke the user confirmed.
|
||||
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||
// …and a retry that still finds a busy project says so without ever
|
||||
// claiming a token was removed.
|
||||
const toast = useAppState.getState().toasts.at(-1)!;
|
||||
expect(toast.message).not.toMatch(/keychain/i);
|
||||
});
|
||||
|
||||
it("will not start a sign-in while a cleanup is still running", async () => {
|
||||
// A sweep is a per-image inspect/create/commit/rmi over the Docker socket
|
||||
// and runs for minutes. Acquiring a token inside that window races the
|
||||
// rewrite that is meant to be removing one.
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
sweepClaudeTokenSnapshots.mockReturnValue(new Promise(() => {}));
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
const authenticate = await screen.findByRole("button", { name: "Authenticate" });
|
||||
expect(authenticate).toBeEnabled();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("shared-auth-sweep"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Authenticate" })).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
// ── A keychain that refuses the delete has to leave something to press ───
|
||||
|
||||
it("keeps both remedies on screen when the keychain refuses the delete", async () => {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(true);
|
||||
clearClaudeToken.mockRejectedValue("the keychain is locked");
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
|
||||
);
|
||||
const toast = useAppState.getState().toasts[0];
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.detail).toMatch(/still stored/i);
|
||||
|
||||
// The token is still there, so Revoke is still the retry for it — and the
|
||||
// images, which the failed delete says nothing about, have their own sweep.
|
||||
await screen.findByRole("button", { name: "Revoke" });
|
||||
const sweep = screen.getByTestId("shared-auth-sweep");
|
||||
fireEvent.click(sweep);
|
||||
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import Modal from "../ui/Modal";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import { selectClass } from "../ui/Field";
|
||||
import ClaudeAuthModal from "./ClaudeAuthModal";
|
||||
import { clearClaudeToken } from "../../lib/tauri-commands";
|
||||
import {
|
||||
clearClaudeToken,
|
||||
sweepClaudeTokenSnapshots,
|
||||
} from "../../lib/tauri-commands";
|
||||
import type { ClearTokenOutcome } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { authErrorMessage, useClaudeTokenStatus } from "../../hooks/useClaudeAuth";
|
||||
@@ -37,6 +41,32 @@ const STATUS_DISPLAY: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `clear_claude_token` reports three separate things about the snapshot images
|
||||
* that `docker commit` baked the token into, and they need three different
|
||||
* sentences. `snapshots_skipped` in particular is **not** a failure of the
|
||||
* rewrite — the project was busy (starting, compacting, migrating) and its
|
||||
* snapshot was never attempted, so the remedy is to run the sweep again, not
|
||||
* to Reset the project and lose both its volumes.
|
||||
*
|
||||
* `snapshots_skipped` is declared on `ClearTokenOutcome`, but it is still read
|
||||
* through `list()` rather than indexed directly: a backend older than this
|
||||
* change does not send the field at all, and a missing skip list must read as
|
||||
* "nothing was skipped" rather than crashing the panel that reports it.
|
||||
*/
|
||||
type RevokeOutcome = ClearTokenOutcome;
|
||||
|
||||
const list = (values: string[] | undefined): string[] => values ?? [];
|
||||
|
||||
/** Whether a copy of the token is known — or suspected — to still be reachable. */
|
||||
function needsAnotherPass(outcome: RevokeOutcome): boolean {
|
||||
return (
|
||||
list(outcome.snapshots_failed).length > 0 ||
|
||||
list(outcome.snapshots_skipped).length > 0 ||
|
||||
Boolean(outcome.docker_unavailable)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-level control for the one long-lived Claude Code token shared by every
|
||||
* project. Acquisition needs a running container to run the CLI in, so the
|
||||
@@ -50,7 +80,14 @@ export default function SharedAuthSettings() {
|
||||
const [pickedId, setPickedId] = useState<string | null>(null);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [confirmRevoke, setConfirmRevoke] = useState(false);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [sweeping, setSweeping] = useState(false);
|
||||
|
||||
// What the last sweep could not finish. Held in its own state rather than
|
||||
// derived from `status`, because that is exactly the bug this fixes: a
|
||||
// revoke clears the keychain, `status` flips to "absent", and the button
|
||||
// that could have retried the snapshot rewrite disappeared with it —
|
||||
// leaving a live ~1-year token in an image and Reset as the only remedy.
|
||||
const [leftover, setLeftover] = useState<RevokeOutcome | null>(null);
|
||||
|
||||
// `claude setup-token` runs inside a container, so only running projects can
|
||||
// host the flow.
|
||||
@@ -61,45 +98,95 @@ export default function SharedAuthSettings() {
|
||||
|
||||
const display = STATUS_DISPLAY[status];
|
||||
|
||||
const handleRevoke = async () => {
|
||||
setRevoking(true);
|
||||
/**
|
||||
* Run one of the two cleanups. **They are different commands**, and that is
|
||||
* the point.
|
||||
*
|
||||
* `"revoke"` deletes the keychain entry and then rewrites the images. It is
|
||||
* destructive, and it is only ever reached through the confirmation modal.
|
||||
*
|
||||
* `"sweep"` rewrites the images and never touches the keychain. Both the
|
||||
* standalone check and the retry offered after an incomplete revocation use
|
||||
* it, because neither is a request to delete a credential — the images are
|
||||
* the durable record of what is left to do, so the work is re-derived from
|
||||
* Docker rather than from any token that happens to be stored.
|
||||
*
|
||||
* While there was one command behind both, the retry button was an
|
||||
* unconfirmed revoke: re-authenticate from the button above the panel, press
|
||||
* "Retry snapshot cleanup", and the token acquired seconds earlier was gone,
|
||||
* announced by a toast that mentioned only images.
|
||||
*/
|
||||
const runCleanup = async (mode: "revoke" | "sweep") => {
|
||||
setSweeping(true);
|
||||
try {
|
||||
const outcome = await clearClaudeToken();
|
||||
const outcome = (await (mode === "revoke"
|
||||
? clearClaudeToken()
|
||||
: sweepClaudeTokenSnapshots())) as RevokeOutcome;
|
||||
setConfirmRevoke(false);
|
||||
await refresh();
|
||||
|
||||
// The keychain entry is gone either way. What matters here is the copy of
|
||||
// the token that `docker commit` baked into each project's snapshot
|
||||
// image: that one outlives every container, and `docker image inspect`
|
||||
// will keep printing it until the image is rewritten. If that could not
|
||||
// be done, the revocation is incomplete and saying "removed" would be a
|
||||
// lie.
|
||||
const failed = list(outcome.snapshots_failed);
|
||||
const skipped = list(outcome.snapshots_skipped);
|
||||
const scrubbed = list(outcome.snapshots_scrubbed);
|
||||
const superseded = list(outcome.snapshots_superseded);
|
||||
|
||||
setLeftover(needsAnotherPass(outcome) ? outcome : null);
|
||||
|
||||
// Whatever the mode, what is being reported here is the copy of the
|
||||
// token that `docker commit` baked into each project's snapshot image:
|
||||
// that one outlives every container, and `docker image inspect` will
|
||||
// keep printing it until the image is rewritten. On a revoke the
|
||||
// keychain entry is already gone by this point — the command deletes it
|
||||
// before it touches an image — so if the rewrite could not be done the
|
||||
// revocation is incomplete and saying "removed" would be a lie. On a
|
||||
// sweep nothing was deleted at all, and the wording must not imply it.
|
||||
if (outcome.docker_unavailable) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but snapshots were not checked.",
|
||||
message:
|
||||
mode === "revoke"
|
||||
? "Token removed from the keychain, but snapshots were not checked."
|
||||
: "Snapshot images were not checked.",
|
||||
detail:
|
||||
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
|
||||
"built before this version may still contain the token in its environment. " +
|
||||
"Start Docker and revoke again to clear them.",
|
||||
"Start Docker and run the cleanup again to clear them.",
|
||||
});
|
||||
} else if (outcome.snapshots_failed.length > 0) {
|
||||
} else if (failed.length > 0) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but it is still in some images.",
|
||||
message:
|
||||
mode === "revoke"
|
||||
? "Token removed from the keychain, but it is still in some images."
|
||||
: "A token is still readable in some snapshot images.",
|
||||
detail:
|
||||
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
|
||||
`${failed.length} snapshot image(s) could not be rewritten and ` +
|
||||
"still contain the token, readable via `docker image inspect`. Reset those " +
|
||||
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
|
||||
`projects to remove the images. Details: ${failed.join("; ")}` +
|
||||
(skipped.length > 0
|
||||
? ` A further ${skipped.length} image(s) were skipped because their projects ` +
|
||||
"are busy; those can be cleared by running the cleanup again."
|
||||
: ""),
|
||||
});
|
||||
} else if (outcome.snapshots_scrubbed.length > 0) {
|
||||
} else if (skipped.length > 0) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Token still in ${skipped.length} snapshot image(s) — those projects were busy.`,
|
||||
detail:
|
||||
"Nothing was rewritten for them, so the token is still readable via " +
|
||||
"`docker image inspect`. Wait for the operation in progress to finish and run the " +
|
||||
`cleanup again. Details: ${skipped.join("; ")}`,
|
||||
});
|
||||
} else if (scrubbed.length > 0) {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
|
||||
message:
|
||||
mode === "revoke"
|
||||
? `Shared Claude token removed, and cleared from ${scrubbed.length} snapshot image(s).`
|
||||
: `Token cleared from ${scrubbed.length} snapshot image(s).`,
|
||||
detail:
|
||||
outcome.snapshots_superseded.length > 0
|
||||
superseded.length > 0
|
||||
? "The pre-rewrite image layer for " +
|
||||
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
|
||||
`${superseded.join(", ")} is still on disk because a ` +
|
||||
"container is running from it. It goes away once that project is restarted " +
|
||||
"(which recreates the container) and Docker prunes the leftover."
|
||||
: undefined,
|
||||
@@ -107,23 +194,44 @@ export default function SharedAuthSettings() {
|
||||
} else {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: "Shared Claude token removed from the keychain.",
|
||||
message:
|
||||
mode === "revoke"
|
||||
? "Shared Claude token removed from the keychain."
|
||||
: "No snapshot image is holding the token.",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not remove the shared Claude token.",
|
||||
detail: authErrorMessage(
|
||||
e,
|
||||
"The OS keychain rejected the delete. The token may still be stored.",
|
||||
),
|
||||
message:
|
||||
mode === "revoke"
|
||||
? "Could not remove the shared Claude token."
|
||||
: "Could not clear the token from snapshot images.",
|
||||
detail:
|
||||
mode === "revoke"
|
||||
? // The keychain delete runs first and nothing else runs until it
|
||||
// succeeds, so the consequence is knowable and worth stating
|
||||
// rather than leaving the user with a raw keyring error: the
|
||||
// token is still stored, and no image was touched. Revoke stays
|
||||
// on screen because `status` is still "stored", and the snapshot
|
||||
// sweep beside it does not need this delete to have worked.
|
||||
`${authErrorMessage(e, "The OS keychain rejected the delete.")} ` +
|
||||
"The token is still stored and no snapshot image was changed — try again, " +
|
||||
"or use “Check snapshot images” to clean the images on their own."
|
||||
: authErrorMessage(e, "The snapshot images could not be checked."),
|
||||
});
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
// In `finally` rather than in the success path: a failed revoke leaves
|
||||
// the token stored, and the panel has to say so rather than keeping
|
||||
// whatever it believed before the attempt.
|
||||
await refresh();
|
||||
setSweeping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leftoverFailed = list(leftover?.snapshots_failed);
|
||||
const leftoverSkipped = list(leftover?.snapshots_skipped);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -179,7 +287,11 @@ export default function SharedAuthSettings() {
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!host}
|
||||
// Also disabled while a cleanup is running. A sweep is a per-image
|
||||
// inspect/create/commit/rmi over the Docker socket and takes minutes;
|
||||
// acquiring a token in the middle of one races the rewrite, and a
|
||||
// revoke started before it would delete the new token when it lands.
|
||||
disabled={!host || sweeping}
|
||||
onClick={() => setAuthOpen(true)}
|
||||
>
|
||||
{status === "stored" ? "Re-authenticate" : "Authenticate"}
|
||||
@@ -188,14 +300,81 @@ export default function SharedAuthSettings() {
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={revoking}
|
||||
disabled={sweeping}
|
||||
onClick={() => setConfirmRevoke(true)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
{status !== "checking" && (
|
||||
// Offered in every state, not just "absent". Three reasons, and the
|
||||
// command behind it deletes nothing, so none of them costs anything:
|
||||
// a snapshot committed by an older build carries a token whether or
|
||||
// not one is stored today; a revoke that could not finish leaves one
|
||||
// in an image while the keychain entry — and the Revoke button — is
|
||||
// already gone; and when the keychain refuses the delete, `status`
|
||||
// stays "stored", which used to leave the images with no affordance
|
||||
// at all.
|
||||
<Button
|
||||
size="md"
|
||||
variant="ghost"
|
||||
disabled={sweeping}
|
||||
data-testid="shared-auth-sweep"
|
||||
onClick={() => void runCleanup("sweep")}
|
||||
>
|
||||
{sweeping ? "Checking…" : "Check snapshot images"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{leftover && (
|
||||
<div
|
||||
data-testid="shared-auth-leftover"
|
||||
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2"
|
||||
>
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label="Token still readable"
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{leftover.docker_unavailable
|
||||
? `Docker could not be reached (${leftover.docker_unavailable}), so no snapshot image was checked.`
|
||||
: null}
|
||||
{leftoverSkipped.length > 0 ? (
|
||||
<>
|
||||
{leftoverSkipped.length} snapshot image(s) were skipped because their
|
||||
projects were busy. Nothing was rewritten for them, so the token is
|
||||
still readable with{" "}
|
||||
<code className="font-mono">docker image inspect</code>. Running the
|
||||
cleanup again once those projects are idle clears them.
|
||||
</>
|
||||
) : null}
|
||||
{leftoverFailed.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
{leftoverFailed.length} snapshot image(s) could not be rewritten:{" "}
|
||||
{leftoverFailed.join("; ")}. If retrying does not help, Reset those
|
||||
projects to remove the images.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
disabled={sweeping}
|
||||
data-testid="shared-auth-retry"
|
||||
// `runCleanup("sweep")` is `sweep_claude_token_snapshots`, which
|
||||
// has no keychain delete on the wire at all — see `runCleanup`.
|
||||
onClick={() => void runCleanup("sweep")}
|
||||
>
|
||||
{sweeping ? "Retrying…" : "Retry snapshot cleanup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!host && (
|
||||
<p
|
||||
data-testid="shared-auth-no-container"
|
||||
@@ -221,6 +400,13 @@ export default function SharedAuthSettings() {
|
||||
projectName={host.name}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onAuthenticated={() => {
|
||||
// The panel is about a *previous* credential and the button that
|
||||
// finishes it is a sweep, so leaving it up after a fresh sign-in
|
||||
// is at best confusing and was at worst fatal: while the retry ran
|
||||
// `clear_claude_token`, the obvious next click deleted the token
|
||||
// just acquired. "Check snapshot images" stays available above, so
|
||||
// nothing is lost by clearing this.
|
||||
setLeftover(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
@@ -237,17 +423,17 @@ export default function SharedAuthSettings() {
|
||||
size="md"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRevoke(false)}
|
||||
disabled={revoking}
|
||||
disabled={sweeping}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={revoking}
|
||||
onClick={() => void handleRevoke()}
|
||||
disabled={sweeping}
|
||||
onClick={() => void runCleanup("revoke")}
|
||||
>
|
||||
{revoking ? "Revoking…" : "Revoke token"}
|
||||
{sweeping ? "Revoking…" : "Revoke token"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -260,11 +446,13 @@ export default function SharedAuthSettings() {
|
||||
restarted.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
||||
Each project’s snapshot image is also rewritten, because{" "}
|
||||
<code className="font-mono">docker commit</code> copies the token into it
|
||||
and an image outlives every container built from it. If any image
|
||||
cannot be rewritten you will be told which, and the token stays readable
|
||||
in it until that project is Reset.
|
||||
The keychain entry goes first, then each project’s snapshot image is
|
||||
rewritten — <code className="font-mono">docker commit</code> copies
|
||||
the token into it, and an image outlives every container built from it.
|
||||
That second half takes a while, and a project that is busy right now is
|
||||
skipped rather than rewritten unsafely: you will be told which, and
|
||||
“Check snapshot images” runs the cleanup again afterwards
|
||||
without touching the keychain.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { formatBytes } from "../../lib/formatBytes";
|
||||
|
||||
interface Props {
|
||||
updateInfo: UpdateInfo;
|
||||
@@ -24,11 +25,6 @@ export default function UpdateDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Update Available"
|
||||
@@ -83,7 +79,11 @@ export default function UpdateDialog({
|
||||
>
|
||||
<span className="truncate font-mono">{asset.name}</span>
|
||||
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||
{formatSize(asset.size)}
|
||||
{/* `binary` because a release asset's size is the ÷1024 figure
|
||||
every OS file browser shows for the same download. This
|
||||
used to be a local copy that rendered KB whole and stopped
|
||||
the ladder at MB; see `formatBytes.ts`. */}
|
||||
{formatBytes(asset.size, { binary: true })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import TerminalView, { supersedes } from "./TerminalView";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
|
||||
/**
|
||||
* The window-wide native drag-drop listener, captured at registration.
|
||||
*
|
||||
* Tauri routes *every* file drop to *every* listener, which is the whole reason
|
||||
* `TerminalView` hit-tests one — so a test that wants to know what the hit test
|
||||
* decides has to be able to fire the event itself.
|
||||
*/
|
||||
const dragDrop = vi.hoisted(() => ({
|
||||
handler: null as null | ((event: unknown) => unknown),
|
||||
}));
|
||||
|
||||
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
|
||||
const ptyOutput = vi.hoisted(() => ({
|
||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Shift+Enter has to reach the container as ESC+CR.
|
||||
*
|
||||
* xterm.js does not consult `shiftKey` for Enter, so Shift+Enter is
|
||||
* byte-identical to Enter unless `attachCustomKeyEventHandler` intervenes —
|
||||
* which means the interesting assertion is not just "ESC+CR was sent" but
|
||||
* "and a bare CR was not", i.e. that the handler returned false and xterm
|
||||
* stopped. A test that only checked the first half would pass on a version
|
||||
* that submits the prompt *and* inserts a newline.
|
||||
*/
|
||||
|
||||
const terminalInput = vi.fn(async () => {});
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
terminalInput: (sessionId: string, bytes: number[]) =>
|
||||
terminalInput(sessionId, bytes),
|
||||
terminalResize: vi.fn(async () => {}),
|
||||
pasteImageToTerminal: vi.fn(async () => ""),
|
||||
openTerminalSession: vi.fn(async () => {}),
|
||||
closeTerminalSession: vi.fn(async () => {}),
|
||||
updateProject: vi.fn(async () => ({})),
|
||||
awsSsoRefresh: vi.fn(async () => {}),
|
||||
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: async (event: string, cb: (e: { payload: number[] }) => void) => {
|
||||
ptyOutput.listeners.set(event, cb);
|
||||
return () => ptyOutput.listeners.delete(event);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
||||
dragDrop.handler = cb;
|
||||
return () => {
|
||||
dragDrop.handler = null;
|
||||
};
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
/** jsdom has no ResizeObserver, and the mount effect installs one. */
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
/** What `sendInput` put on the wire, decoded back to a string. */
|
||||
function sent(): string[] {
|
||||
return terminalInput.mock.calls.map((call) =>
|
||||
new TextDecoder().decode(new Uint8Array((call as unknown as [string, number[]])[1])),
|
||||
);
|
||||
}
|
||||
|
||||
function mountSession(sessionType: "claude" | "bash") {
|
||||
useAppState.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api",
|
||||
sessionType,
|
||||
sessionName: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return render(<TerminalView sessionId="s1" active />);
|
||||
}
|
||||
|
||||
/** The hidden textarea xterm binds its keyboard handling to. */
|
||||
function helperTextarea(container: HTMLElement): HTMLTextAreaElement {
|
||||
const el = container.querySelector<HTMLTextAreaElement>(
|
||||
"textarea.xterm-helper-textarea",
|
||||
);
|
||||
if (!el) throw new Error("xterm helper textarea not found");
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", NoopResizeObserver);
|
||||
// xterm's renderer asks the window for its device pixel ratio on open.
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
(query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
onchange: null,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
);
|
||||
terminalInput.mockClear();
|
||||
vi.mocked(uploadHostFileToTerminal).mockClear();
|
||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||
dragDrop.handler = null;
|
||||
ptyOutput.listeners.clear();
|
||||
document.body.innerHTML = "";
|
||||
useAppState.setState({ sessions: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("TerminalView — Shift+Enter", () => {
|
||||
it("sends ESC+CR and cancels the keydown, so no bare CR follows", () => {
|
||||
// **The cancel is the load-bearing half, and this test could not see it.**
|
||||
//
|
||||
// Returning `false` from xterm's custom key handler does not cancel the
|
||||
// event: `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still runs and emits a bare CR for Enter's charCode 13. In a
|
||||
// real browser that submitted the prompt straight after inserting the
|
||||
// newline. jsdom never synthesizes the follow-up keypress, so the old
|
||||
// `expect(sent()).not.toContain("\r")` assertion below could not fail no
|
||||
// matter what the code did — it was named for a behaviour it could not
|
||||
// exercise.
|
||||
//
|
||||
// Asserting `defaultPrevented` pins the actual mechanism that stops the
|
||||
// keypress, which is a property jsdom *can* observe.
|
||||
const { container } = mountSession("claude");
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
helperTextarea(container).dispatchEvent(event);
|
||||
|
||||
// The bytes `/terminal-setup` installs for every other editor.
|
||||
expect(sent()).toEqual(["\x1b\r"]);
|
||||
expect(sent()).not.toContain("\r");
|
||||
// Without this, the browser fires keypress and xterm submits.
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a plain Enter alone", () => {
|
||||
const { container } = mountSession("claude");
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), { key: "Enter", keyCode: 13 });
|
||||
|
||||
expect(sent()).toEqual(["\r"]);
|
||||
});
|
||||
|
||||
it("does not bind it in a bash session", () => {
|
||||
// `bash -l` runs readline, which has no binding for `\e\r`: it would answer
|
||||
// with a bell and swallow the Enter the user actually pressed.
|
||||
const { container } = mountSession("bash");
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
expect(sent()).toEqual(["\r"]);
|
||||
});
|
||||
|
||||
it("leaves a modified Shift+Enter to xterm", () => {
|
||||
// Adding Ctrl is not the chord this binds; whatever xterm does with it is
|
||||
// xterm's business.
|
||||
const { container } = mountSession("claude");
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
expect(sent()).not.toContain("\x1b\r");
|
||||
});
|
||||
|
||||
it("Alt+Enter already produced ESC+CR without any handler", () => {
|
||||
// Pinned because it is the reason Shift+Enter was the only gap: xterm
|
||||
// ESC-prefixes on `altKey` by itself, so Alt+Enter has always inserted a
|
||||
// newline in Claude Code. It was simply undocumented.
|
||||
const { container } = mountSession("bash"); // no custom branch involved
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(sent()).toEqual(["\x1b\r"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersedes — who owns the prompt slot", () => {
|
||||
const relay = (url: string) => ({ url, source: "relay" as const });
|
||||
const osc8 = (url: string) => ({ url, source: "osc8" as const });
|
||||
const guess = (url: string) => ({ url, source: "heuristic" as const });
|
||||
|
||||
const COMPLETE =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference";
|
||||
// What the screen-scraper reconstructs from the visible text: parses, points
|
||||
// at the right host, authorises nothing.
|
||||
const TRUNCATED = COMPLETE.slice(0, 80);
|
||||
|
||||
it("fills an empty slot from anywhere", () => {
|
||||
expect(supersedes(guess(TRUNCATED), null)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to let a truncated guess replace the exact copy", () => {
|
||||
// The whole bug: the relay lands first with the complete URL, and 300 ms
|
||||
// later the detector's debounce fires with a prefix of it.
|
||||
expect(supersedes(guess(TRUNCATED), relay(COMPLETE))).toBe(false);
|
||||
expect(supersedes(guess(TRUNCATED), osc8(COMPLETE))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a better source take over from a worse one", () => {
|
||||
expect(supersedes(osc8(COMPLETE), guess(TRUNCATED))).toBe(true);
|
||||
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to let a truncated guess replace another guess it truncates", () => {
|
||||
// The same rule one rank down. Both are scrapes of the same repainting
|
||||
// frame, so recency says the newer one wins and recency is wrong: a
|
||||
// repaint that lands a *shorter* view of the link already on screen is
|
||||
// showing less of it, not something new.
|
||||
expect(supersedes(guess(TRUNCATED), guess(COMPLETE))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a scraped candidate grow into the complete link", () => {
|
||||
// A repaint can land the truncated copy first. Extending it is safe: a
|
||||
// longer string with the same prefix has the same origin.
|
||||
expect(supersedes(guess(COMPLETE), guess(TRUNCATED))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let an unrelated scrape displace what is on screen", () => {
|
||||
// Longest-wins without the prefix test hands the choice to whoever pads
|
||||
// their URL the most.
|
||||
expect(
|
||||
supersedes(guess("https://evil.tld/" + "a".repeat(400)), guess(COMPLETE)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a second explicit relay request through", () => {
|
||||
// Each OSC 7777 is a fresh deliberate ask, not another view of the last
|
||||
// one — a second `gh auth login` must be able to replace the first.
|
||||
expect(
|
||||
supersedes(relay("https://github.com/login/device"), relay(COMPLETE)),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — where a dropped file lands", () => {
|
||||
/** Mount, let the async drag-drop registration settle, and give the pane a
|
||||
* rect — jsdom has no layout, so every element is 0×0 and would be rejected
|
||||
* as a hidden pane.
|
||||
*
|
||||
* The rect goes on the *pane wrapper*, which is what the hit test asks
|
||||
* about: it is what the user sees as the terminal (gutter included), and
|
||||
* the chrome painted over it — the Following toggle, the URL toast — are
|
||||
* its children rather than the xterm host's. */
|
||||
async function mountWithLayout() {
|
||||
const view = mountSession("bash");
|
||||
await act(async () => {});
|
||||
const pane = view.container.firstElementChild as HTMLElement | null;
|
||||
if (!pane) throw new Error("terminal pane not found");
|
||||
pane.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
return view;
|
||||
}
|
||||
|
||||
/** jsdom has no `elementFromPoint`, so a z-order branch is unreachable
|
||||
* unless a test supplies one — which is exactly how a gate that refused
|
||||
* every drop under the Following toggle shipped through this file green.
|
||||
* The gate asks no per-point question any more, but the tests below still
|
||||
* install one and feed it the most misleading answer available, to pin
|
||||
* that the routing does not change when it is there. */
|
||||
function stubElementFromPoint(top: Element | null) {
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => top,
|
||||
});
|
||||
}
|
||||
|
||||
async function drop(x: number, y: number) {
|
||||
if (!dragDrop.handler) throw new Error("no drag-drop listener registered");
|
||||
await act(async () => {
|
||||
await dragDrop.handler!({
|
||||
payload: { type: "drop", position: { x, y }, paths: ["/host/dropped.txt"] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("uploads a file dropped onto the pane", async () => {
|
||||
await mountWithLayout();
|
||||
await drop(400, 300);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
"/host/dropped.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a drop that lands outside the pane", async () => {
|
||||
await mountWithLayout();
|
||||
await drop(4000, 300);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a drop released onto an open modal", async () => {
|
||||
// The hit test used to be purely geometric, and a `Modal` is a
|
||||
// `fixed inset-0 z-50` portal painted *over* the whole window — so the pane
|
||||
// underneath still had its rect and happily uploaded the file into the
|
||||
// directory the dialog was covering. Same for the shutdown overlay, which is
|
||||
// up precisely while nothing should be accepting work.
|
||||
await mountWithLayout();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
await drop(400, 300);
|
||||
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
|
||||
// …and it is the modal, not the mount, that is refusing: close it and the
|
||||
// very same drop goes through.
|
||||
dialog.remove();
|
||||
await drop(400, 300);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
||||
// The regression this file could not see. The toggle is `absolute top-2
|
||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||
// with no message and no log line. jsdom never ran that branch.
|
||||
const view = await mountWithLayout();
|
||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
||||
stubElementFromPoint(toggle);
|
||||
|
||||
await drop(780, 10);
|
||||
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
"/host/dropped.txt",
|
||||
);
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
});
|
||||
|
||||
it("refuses — and says so — while a dialog is open", async () => {
|
||||
useAppState.setState({ toasts: [] });
|
||||
await mountWithLayout();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
backdrop.appendChild(panel);
|
||||
document.body.appendChild(backdrop);
|
||||
stubElementFromPoint(backdrop);
|
||||
|
||||
await drop(400, 300);
|
||||
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
// A refused drop is otherwise indistinguishable from a broken one.
|
||||
const notice = useAppState
|
||||
.getState()
|
||||
.toasts.find((t) => t.message === "File drop ignored");
|
||||
expect(notice).toBeTruthy();
|
||||
// Not an error: the user has a dialog open, which is a state they chose
|
||||
// and can leave with Escape. An error card would sit there until
|
||||
// dismissed, and `ToastHost` paints at `z-[60]`.
|
||||
expect(notice?.kind).toBe("info");
|
||||
|
||||
backdrop.remove();
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
});
|
||||
|
||||
it("keeps refusing when the refusal's own toast is painted over the dialog", async () => {
|
||||
// C1, end to end. Refusing pushes a toast; `ToastHost` is `fixed
|
||||
// bottom-4 right-4 z-[60]` and the `Modal` backdrop is `z-50` in the same
|
||||
// stacking context — so the toast is the topmost element over the covered
|
||||
// pane, and a gate that asked "is a blocker painted here?" answered no and
|
||||
// uploaded into the directory the dialog was covering. The gate had armed
|
||||
// its own hole: one refused drop was all it took to open it.
|
||||
useAppState.setState({ toasts: [] });
|
||||
await mountWithLayout();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(backdrop);
|
||||
stubElementFromPoint(backdrop);
|
||||
|
||||
await drop(400, 300);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
expect(useAppState.getState().toasts).toHaveLength(1);
|
||||
|
||||
// The toast is now on screen, above the backdrop, and the user drops again
|
||||
// on the very point it occupies.
|
||||
const toastCard = document.createElement("div");
|
||||
document.body.appendChild(toastCard);
|
||||
stubElementFromPoint(toastCard);
|
||||
|
||||
await drop(700, 550);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
// …and a second refusal replaces the first notice rather than stacking.
|
||||
expect(useAppState.getState().toasts).toHaveLength(1);
|
||||
|
||||
toastCard.remove();
|
||||
backdrop.remove();
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
||||
// This toast is the only route to completing a sign-in started in a terminal.
|
||||
// It used to be mouse-only: nothing moved focus to it, nothing dismissed it
|
||||
// from the keyboard, and xterm's helper textarea eats Tab, so its buttons
|
||||
// could not be reached at all.
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
/** What `container/triple-c-open` writes to its controlling terminal. */
|
||||
function relaySequence(url: string): number[] {
|
||||
const payload = btoa(url);
|
||||
return Array.from(
|
||||
new TextEncoder().encode(`\x1b]7777;open;${payload}\x07`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Mount, and let the container ask for a URL to be opened. */
|
||||
async function mountWithPrompt() {
|
||||
const view = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: relaySequence(SIGN_IN) });
|
||||
// xterm parses on its own write queue.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
function primaryAction(): HTMLElement {
|
||||
const el = document.querySelector<HTMLElement>('[data-url-toast-primary="true"]');
|
||||
if (!el) throw new Error("toast default action not found");
|
||||
return el;
|
||||
}
|
||||
|
||||
it("does not take focus away from the terminal when the prompt appears", async () => {
|
||||
// Deliberate: the terminal is live, and the default action opens a URL the
|
||||
// *container* chose. A focused button is one stray Enter from doing it.
|
||||
const { container } = await mountWithPrompt();
|
||||
expect(document.querySelector('[data-testid="url-toast"]')).not.toBeNull();
|
||||
expect(document.activeElement).toBe(helperTextarea(container));
|
||||
});
|
||||
|
||||
it("jumps to the default action on Ctrl+Shift+O", async () => {
|
||||
const { container } = await mountWithPrompt();
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "O",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(primaryAction());
|
||||
});
|
||||
|
||||
it("dismisses on Escape and hands focus back to the terminal", async () => {
|
||||
// Not back to `document.body`, where the next keystroke goes nowhere.
|
||||
const { container } = await mountWithPrompt();
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "O",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
|
||||
expect(document.querySelector('[data-testid="url-toast"]')).toBeNull();
|
||||
expect(document.activeElement).toBe(helperTextarea(container));
|
||||
});
|
||||
|
||||
it("leaves Ctrl+Shift+O to the terminal when there is no prompt", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const before = document.activeElement;
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "O",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,20 @@ import {
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
|
||||
import {
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
extendsUrl,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import UrlToast from "./UrlToast";
|
||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||
import UrlToast, {
|
||||
URL_TOAST_PRIMARY_SELECTOR,
|
||||
URL_TOAST_SELECTOR,
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
|
||||
@@ -29,6 +35,58 @@ interface Props {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a prompted URL came from.
|
||||
*
|
||||
* `relay` is the container asking explicitly, over OSC 7777, with the URL
|
||||
* base64-encoded — exact by construction. `osc8` is lifted verbatim out of a
|
||||
* hyperlink parameter — also exact, but nobody asked for it. `heuristic` was
|
||||
* reassembled from painted text and is the only one that can be a *truncated
|
||||
* guess* at the link it is showing.
|
||||
*/
|
||||
export type PromptSource = "relay" | UrlSource;
|
||||
|
||||
/** Higher wins. Provenance, not recency. */
|
||||
const SOURCE_RANK: Record<PromptSource, number> = {
|
||||
heuristic: 0,
|
||||
osc8: 1,
|
||||
relay: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether `next` may take over the prompt slot from `current`.
|
||||
*
|
||||
* The bug this exists for: `claude login` relays its OAuth URL over OSC 7777,
|
||||
* base64-encoded and therefore complete; the screen-scraper's 300 ms debounce
|
||||
* then fires, finds the same link cut into terminal-width pieces, and — under
|
||||
* the old last-writer-wins slot — replaced the good URL with a truncated one
|
||||
* that still parses, still points at the right host, and cannot authorise
|
||||
* anything. The user is the one who has to notice.
|
||||
*
|
||||
* Two rules, in order:
|
||||
*
|
||||
* - Better provenance always wins, worse provenance never does. A scraped
|
||||
* guess cannot displace an exact copy.
|
||||
* - Between equals, only an *extension* of what is showing may replace it.
|
||||
* That is {@link extendsUrl}, the same rule and the same reasoning as
|
||||
* `pickSignInUrl` in `hooks/useClaudeAuth.ts`: a repaint can land a
|
||||
* truncated copy before the complete one, and a longer string sharing a
|
||||
* prefix cannot move the origin. The relay is exempt because each OSC 7777
|
||||
* is a fresh deliberate request rather than another view of the last one —
|
||||
* a second `gh auth login` must be able to replace the first.
|
||||
*/
|
||||
export function supersedes(
|
||||
next: { url: string; source: PromptSource },
|
||||
current: { url: string; source: PromptSource } | null,
|
||||
): boolean {
|
||||
if (!current) return true;
|
||||
if (SOURCE_RANK[next.source] !== SOURCE_RANK[current.source]) {
|
||||
return SOURCE_RANK[next.source] > SOURCE_RANK[current.source];
|
||||
}
|
||||
if (next.source === "relay") return true;
|
||||
return extendsUrl(next.url, current.url);
|
||||
}
|
||||
|
||||
export default function TerminalView({ sessionId, active }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,13 +105,24 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||||
);
|
||||
|
||||
// One toast slot, two producers: the heuristic long-URL detector and the
|
||||
// container's explicit "open this in the host browser" relay (OSC 7777).
|
||||
// Sharing the slot keeps them from stacking on top of each other.
|
||||
// Which program is on the other end of the PTY. Read through a ref because
|
||||
// the key handler is registered once, in the mount effect keyed on
|
||||
// `sessionId`, and a value captured there would go stale if the session
|
||||
// record arrived after the first render.
|
||||
const sessionType = useAppState(
|
||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.sessionType
|
||||
);
|
||||
const sessionTypeRef = useRef(sessionType);
|
||||
sessionTypeRef.current = sessionType;
|
||||
|
||||
// One toast slot, three producers: the container's explicit "open this in the
|
||||
// host browser" relay (OSC 7777), OSC 8 hyperlink targets, and the heuristic
|
||||
// long-URL detector. Sharing the slot keeps them from stacking on top of each
|
||||
// other.
|
||||
//
|
||||
// Both producers read the container's PTY output, so both are untrusted, and
|
||||
// both must go through `sanitizeRelayUrl` before anything is stored here —
|
||||
// see `promptUrl` below, which is the only writer.
|
||||
// All three read the container's PTY output, so all three are untrusted, and
|
||||
// all three must go through `sanitizeRelayUrl` before anything is stored here
|
||||
// — see `promptUrl` below, which is the only writer.
|
||||
//
|
||||
// `seq` exists because the slot is shared and long-lived: a second prompt
|
||||
// replacing a first would otherwise mutate the toast in place, swapping the
|
||||
@@ -62,25 +131,89 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const [urlPrompt, setUrlPrompt] = useState<{
|
||||
url: string;
|
||||
label: string;
|
||||
source: PromptSource;
|
||||
seq: number;
|
||||
} | null>(null);
|
||||
const promptSeqRef = useRef(0);
|
||||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
||||
// Read by the long-lived keyboard listener below, which is registered once
|
||||
// and would otherwise close over the prompt as it was at mount.
|
||||
const urlPromptRef = useRef<{ url: string } | null>(null);
|
||||
|
||||
/**
|
||||
* Empty the prompt slot, and put focus somewhere real if it was inside the
|
||||
* toast.
|
||||
*
|
||||
* The toast never *takes* focus — see the note in `UrlToast` — but a keyboard
|
||||
* user who jumped into it with {@link URL_TOAST_SHORTCUT} is standing on a
|
||||
* node that is about to unmount, and React does not rehome focus: it lands on
|
||||
* `document.body`, where the terminal receives nothing and the next keystroke
|
||||
* goes nowhere. Every route out of the toast goes through here for that
|
||||
* reason — Open, In container, ✕, Escape and the auto-dismiss alike.
|
||||
*/
|
||||
const dismissUrlPrompt = useCallback(() => {
|
||||
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
|
||||
setUrlPrompt(null);
|
||||
if (wasInside) termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The only writer of the prompt slot. Re-validates whatever the caller
|
||||
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||||
* but the heuristic detector branch has been through nothing at all, and a
|
||||
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
|
||||
*
|
||||
* Last-writer-wins is what this used to be, and it lost the OAuth URL every
|
||||
* time: the relay delivers the link base64-encoded and therefore exact, and
|
||||
* ~300 ms later the screen-scraper's debounce fired and overwrote it with a
|
||||
* truncated guess at the same link. `supersedes` is the fix — see there.
|
||||
*/
|
||||
const promptUrl = useCallback((raw: string, label: string) => {
|
||||
const url = sanitizeRelayUrl(raw);
|
||||
if (!url) {
|
||||
console.warn("Refusing to prompt for a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
promptSeqRef.current += 1;
|
||||
setUrlPrompt({ url, label, seq: promptSeqRef.current });
|
||||
const promptUrl = useCallback(
|
||||
(raw: string, label: string, source: PromptSource) => {
|
||||
const url = sanitizeRelayUrl(raw);
|
||||
if (!url) {
|
||||
console.warn("Refusing to prompt for a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
setUrlPrompt((current) => {
|
||||
if (!supersedes({ url, source }, current)) return current;
|
||||
promptSeqRef.current += 1;
|
||||
return { url, label, source, seq: promptSeqRef.current };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
urlPromptRef.current = urlPrompt;
|
||||
}, [urlPrompt]);
|
||||
|
||||
/**
|
||||
* The keyboard route into the toast.
|
||||
*
|
||||
* Registered on `document` in the capture phase for the same reason
|
||||
* `useKeyboardShortcuts` does it there: xterm would otherwise forward the
|
||||
* chord to the shell. It is *not* added to that hook because the target is
|
||||
* this pane's own toast — the hook has no way to name it, and only one pane
|
||||
* is on screen at a time, which is what `activeRef` checks.
|
||||
*
|
||||
* Nothing is swallowed unless there is a prompt to jump to, so Ctrl+Shift+O
|
||||
* reaches the terminal untouched the rest of the time.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== "o" && e.key !== "O") return;
|
||||
if (!activeRef.current || !urlPromptRef.current) return;
|
||||
const primary = terminalContainerRef.current?.querySelector<HTMLElement>(
|
||||
`${URL_TOAST_SELECTOR} ${URL_TOAST_PRIMARY_SELECTOR}`,
|
||||
);
|
||||
if (!primary) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
primary.focus();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, []);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
@@ -101,24 +234,28 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// in-container paths typed into the prompt so Claude Code can read them.
|
||||
// Tauri intercepts OS file drops at the webview level, so we use
|
||||
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
|
||||
// The listener is window-wide, so we route purely by a hit-test against this
|
||||
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
|
||||
// `display:none` (zero-size rect) so they never match — this works for the
|
||||
// current tabbed layout and would also do the right thing with split panes.
|
||||
//
|
||||
// The listener is window-wide, so every pane decides for itself whether a
|
||||
// drop was meant for it. `classifyDrop` is that decision, shared with the
|
||||
// Files pane, and it asks two things in order: is the payload position
|
||||
// inside this pane's rect (a hidden pane is `display:none`, so its zero-size
|
||||
// rect is what stops two panes both claiming the drop), and — document-wide,
|
||||
// with no geometry — is a modal or blocking overlay on screen at all? An
|
||||
// open `Modal` is a `fixed inset-0` portal painted *over* the window and the
|
||||
// pane underneath still has its rect, so a rect alone uploaded files into
|
||||
// the directory a dialog was covering. See `lib/dropTarget.ts` for why the
|
||||
// blocking half is deliberately not a per-point z-order test.
|
||||
//
|
||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
||||
// drop on its own account — asking "is this element mine?" once turned every
|
||||
// pixel under that chrome into a permanent dead zone.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
// A hidden (display:none) pane has a zero-size rect — never a drop target.
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return false;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const x = pos.x / dpr;
|
||||
const y = pos.y / dpr;
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
};
|
||||
|
||||
// Always single-quote: a dropped filename can contain shell metacharacters
|
||||
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
||||
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
||||
@@ -127,7 +264,24 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
(async () => {
|
||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== "drop") return;
|
||||
if (!insideThisTerminal(event.payload.position)) return;
|
||||
const verdict = classifyDrop(
|
||||
terminalContainerRef.current,
|
||||
event.payload.position,
|
||||
);
|
||||
// A refused drop is invisible — the file simply does not arrive — so
|
||||
// the one case where the user aimed at us and we said no gets both a
|
||||
// log line and something on screen. The toast, not `imagePasteMsg`:
|
||||
// whatever refused this is painted over the terminal, and `ToastHost`
|
||||
// sits above it.
|
||||
if (verdict === "blocked") {
|
||||
console.warn(
|
||||
"[drop] refused: a dialog or overlay is open",
|
||||
event.payload.position,
|
||||
);
|
||||
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
|
||||
const paths = event.payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
@@ -234,6 +388,46 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
useAppState.getState().sttToggle();
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||
// byte-identical to Enter and submits.
|
||||
//
|
||||
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
|
||||
// meta, and it is exactly what its own `/terminal-setup` writes into the
|
||||
// VS Code, Cursor, Alacritty and Zed keymaps. These are the in-band
|
||||
// bytes, not a guess, which is why this must NOT be "simplified" to
|
||||
// `\n`: Claude Code accepts `\n` too, but a shell would *run* the line,
|
||||
// so the two session types would quietly diverge.
|
||||
//
|
||||
// Scoped to Claude sessions for the same reason. A bash tab runs
|
||||
// `bash -l`, where readline has no binding for `\e\r` and answers with a
|
||||
// bell — harmless, but there is nothing to gain from sending it.
|
||||
if (
|
||||
event.type === "keydown" &&
|
||||
event.key === "Enter" &&
|
||||
event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.isComposing &&
|
||||
sessionTypeRef.current === "claude"
|
||||
) {
|
||||
sendInput(sessionId, "\x1b\r");
|
||||
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
||||
//
|
||||
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
||||
// — *before* it sets `_keyDownHandled` and before it cancels the event.
|
||||
// `_keyPress` then checks that same flag, finds it still false, and
|
||||
// emits a bare CR for Enter's charCode 13. So returning `false` alone
|
||||
// sent ESC+CR *and* a submit: the newline was inserted and the
|
||||
// half-written prompt went to Claude with a stray blank line in it.
|
||||
// Cancelling the keydown is what stops the browser firing keypress at
|
||||
// all. Verified in Chromium; jsdom never synthesizes the follow-up
|
||||
// keypress, which is why the unit test could not see this.
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -287,7 +481,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
console.warn("URL relay: rate-limited", url);
|
||||
return true;
|
||||
}
|
||||
promptUrl(url, "Container asked to open a URL");
|
||||
// Exact by construction (base64 over OSC 7777), and the detector never
|
||||
// sees it — so tell it, or a truncated scrape of the same link could
|
||||
// still fill the slot once this prompt is dismissed.
|
||||
detectorRef.current?.noteExactUrl(url);
|
||||
promptUrl(url, "Container asked to open a URL", "relay");
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -374,11 +572,17 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
// The width is read per scan, not captured: only a break the terminal
|
||||
// The detector samples this getter on every `feed`, so what it reassembles
|
||||
// with is the width the bytes were *printed* at — only a break the terminal
|
||||
// itself inserted may be deleted, and where that is moves with every
|
||||
// resize.
|
||||
const detector = new UrlDetector(
|
||||
(url) => promptUrl(url, "Long URL detected"),
|
||||
(url, source) =>
|
||||
promptUrl(
|
||||
url,
|
||||
source === "osc8" ? "Link detected" : "Long URL detected",
|
||||
source,
|
||||
),
|
||||
() => termRef.current?.cols ?? 0,
|
||||
);
|
||||
detectorRef.current = detector;
|
||||
@@ -509,12 +713,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds
|
||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||
// pulling it out from under them costs them the only route to finishing a
|
||||
// sign-in. It goes when they act on it, which is the same thing a mouse user
|
||||
// does by clicking.
|
||||
useEffect(() => {
|
||||
if (!urlPrompt) return;
|
||||
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
|
||||
const timer = setTimeout(() => {
|
||||
if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return;
|
||||
dismissUrlPrompt();
|
||||
}, 30_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [urlPrompt]);
|
||||
}, [urlPrompt, dismissUrlPrompt]);
|
||||
|
||||
// Auto-dismiss image paste message after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -529,13 +740,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||
// precisely when it matters that the last thing before `openUrl` checks.
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt]);
|
||||
}, [urlPrompt, dismissUrlPrompt]);
|
||||
|
||||
/**
|
||||
* Open the prompted URL in the container's own browser instead of the host's.
|
||||
@@ -548,7 +759,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const handleOpenUrlInContainer = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
@@ -580,7 +791,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
detail: String(e),
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, projectId]);
|
||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
@@ -660,7 +871,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
onDismiss={() => setUrlPrompt(null)}
|
||||
onDismiss={dismissUrlPrompt}
|
||||
/>
|
||||
)}
|
||||
{imagePasteMsg && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import UrlToast from "./UrlToast";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import UrlToast, { URL_TOAST_PRIMARY_SELECTOR } from "./UrlToast";
|
||||
|
||||
/**
|
||||
* The toast is the *only* thing standing between a container-chosen URL and
|
||||
@@ -58,4 +58,184 @@ describe("UrlToast", () => {
|
||||
screen.getByRole("button", { name: "Open" }).click();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("keyboard", () => {
|
||||
// This toast is the only route to completing a sign-in started in a
|
||||
// terminal, and xterm's helper textarea swallows Tab — so without these it
|
||||
// is unreachable for a keyboard-only user.
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
it("does not take focus from the live terminal when it appears", () => {
|
||||
// Deliberate. The user may be mid-command, and the default action opens a
|
||||
// URL the *container* chose — a focused button is one stray Enter away
|
||||
// from doing it. The shortcut hint below is what makes that affordable.
|
||||
render(
|
||||
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
|
||||
);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it("says how to reach it, since nothing announces a shortcut by itself", () => {
|
||||
render(
|
||||
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
|
||||
);
|
||||
expect(screen.getByTestId("url-toast-shortcut")).toHaveTextContent(
|
||||
"Ctrl+Shift+O",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the default action so the shortcut has somewhere to land", () => {
|
||||
// Which button that is depends on the URL, so the marker moves with the
|
||||
// decision rather than the owner having to repeat it.
|
||||
const { rerender } = render(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
|
||||
).toHaveTextContent("Open");
|
||||
|
||||
rerender(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
|
||||
).toHaveTextContent("In container");
|
||||
});
|
||||
|
||||
it("dismisses on Escape from anywhere inside it", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://example.com/"
|
||||
onOpen={noop}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Open" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not answer Escape pressed outside it", () => {
|
||||
// Escape belongs to whatever is running in the terminal — vim, above all.
|
||||
// A document-level binding would break it for everyone who never looked
|
||||
// at this toast.
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://example.com/"
|
||||
onOpen={noop}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives every action a real button, so Tab reaches all three", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
const names = screen
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(names).toEqual(["In container", "Open", "Dismiss"]);
|
||||
// Nothing is taken out of the tab order.
|
||||
for (const b of screen.getAllByRole("button")) {
|
||||
expect(b).not.toHaveAttribute("tabindex", "-1");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anthropic sign-in links", () => {
|
||||
// The callback listener a `claude login` is waiting on is *inside* the
|
||||
// container. Sending the user to their host browser completes the sign-in
|
||||
// and then posts the result where nothing is listening, and the terminal
|
||||
// hangs to its timeout — so for these, and only these, the container-side
|
||||
// browser leads.
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
function actions() {
|
||||
return screen
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
.filter((t) => t === "Open" || t === "In container");
|
||||
}
|
||||
|
||||
it("puts the container browser first", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["In container", "Open"]);
|
||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
||||
/callback listener is inside the container/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the host browser available as a fallback", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={onOpen}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
screen.getByRole("button", { name: "Open" }).click();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves an ordinary URL alone", () => {
|
||||
// A `gh auth login` device code, a docs page, a preview build — the host
|
||||
// browser is the right answer for all of them and stays the default.
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
expect(screen.queryByTestId("url-toast-signin-hint")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not fooled by a lookalike host", () => {
|
||||
// `isAnthropicSignInUrl` uses the same allowlist the sign-in flow does,
|
||||
// so a URL that merely says "claude.ai" somewhere is not one.
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
import { urlOrigin } from "../../lib/urlRelay";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
* Marks the toast's subtree. `TerminalView` uses it to answer "is focus inside
|
||||
* the thing I am about to unmount?", which is what decides whether dismissing
|
||||
* has to hand focus back to the terminal.
|
||||
*/
|
||||
export const URL_TOAST_SELECTOR = '[data-testid="url-toast"]';
|
||||
|
||||
/**
|
||||
* The chord that jumps from the terminal into this toast.
|
||||
*
|
||||
* Bound in `TerminalView` on `document` in the capture phase, the same way
|
||||
* `useKeyboardShortcuts` binds the app's other chords, because xterm would
|
||||
* otherwise forward it to the shell. Shift is what keeps it clear of the
|
||||
* terminal: plain Ctrl+O is readline's `operate-and-get-next`.
|
||||
*/
|
||||
export const URL_TOAST_SHORTCUT = "Ctrl+Shift+O";
|
||||
|
||||
/**
|
||||
* Marks the *default* action inside the toast, so the owner can put focus
|
||||
* there without a ref threaded through `ui/Button` — which is a plain function
|
||||
* component and not this file's to change. Which button it is depends on the
|
||||
* URL (see the sign-in note below), so the attribute moves with the decision
|
||||
* rather than the caller having to repeat it.
|
||||
*/
|
||||
export const URL_TOAST_PRIMARY_SELECTOR = '[data-url-toast-primary="true"]';
|
||||
|
||||
interface Props {
|
||||
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
||||
@@ -28,6 +56,40 @@ interface Props {
|
||||
* is shared and long-lived, so without one React mutates the node in place: the
|
||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||
* B that arrived a second later.
|
||||
*
|
||||
* ## Anthropic sign-in links default to the container's browser
|
||||
*
|
||||
* For an ordinary URL the host browser is the right answer and stays the
|
||||
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
|
||||
* is waiting on is inside the container, so a host browser completes the sign-in
|
||||
* and then posts the result somewhere nothing is listening, and the terminal
|
||||
* hangs until it times out. Making the host button primary there was quietly
|
||||
* steering every user into that. The container-side browser closes the loop
|
||||
* with no host round trip and no auth bridge, so it leads — and the host button
|
||||
* stays, because a user who has the auth bridge on, or who wants their existing
|
||||
* browser session, still needs it.
|
||||
*
|
||||
* ## Reachable without a mouse, and it does not take focus to manage it
|
||||
*
|
||||
* This toast is the only route to completing a sign-in started in a terminal,
|
||||
* and it used to be mouse-only: xterm's helper textarea swallows Tab, so there
|
||||
* was no way to reach these buttons at all from the keyboard.
|
||||
*
|
||||
* The obvious fix — focus the default action when the toast appears — was
|
||||
* rejected on two counts. The terminal underneath is *live*: the user may be
|
||||
* mid-command, and every keystroke after the steal would go to a button instead
|
||||
* of the shell. Worse, the default action opens a URL chosen by the untrusted
|
||||
* side of the sandbox, and a focused button is one stray Space or Enter away
|
||||
* from doing it. This prompt exists precisely to make that a deliberate act.
|
||||
*
|
||||
* So focus stays where the user put it and the toast is reachable on demand:
|
||||
* {@link URL_TOAST_SHORTCUT} jumps to the default action (the hint is on
|
||||
* screen, next to the label, because a shortcut nobody is told about is not a
|
||||
* route), Tab then moves between the actions normally — this subtree is not
|
||||
* inside xterm — and Escape dismisses. Escape is handled *here*, on the
|
||||
* toast's own subtree, rather than globally: Escape belongs to whatever is
|
||||
* running in the terminal, and a document-level binding for it would break vim
|
||||
* for everyone who never looked at this toast.
|
||||
*/
|
||||
export default function UrlToast({
|
||||
url,
|
||||
@@ -38,11 +100,60 @@ export default function UrlToast({
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
||||
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
||||
// host button is the only action there is, so it stays primary.
|
||||
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
||||
|
||||
// `Button` already owns the filled/outlined variants — including the rule
|
||||
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||
// foreground/link accent and fails WCAG AA behind white text.
|
||||
const hostButton = (
|
||||
<Button
|
||||
variant={signIn ? "secondary" : "primary"}
|
||||
data-url-toast-primary={signIn ? undefined : "true"}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
title={
|
||||
signIn
|
||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
);
|
||||
|
||||
const containerButton = onOpenInContainer && (
|
||||
// A sign-in completed in the *container's* browser lands its callback on
|
||||
// the container's own loopback, which is where the tool waiting for it is
|
||||
// listening — no host round trip, no auth bridge.
|
||||
<Button
|
||||
variant={signIn ? "primary" : "secondary"}
|
||||
data-url-toast-primary={signIn ? "true" : undefined}
|
||||
onClick={onOpenInContainer}
|
||||
className="flex-shrink-0"
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
>
|
||||
In container
|
||||
</Button>
|
||||
);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// Scoped to this subtree, so the terminal's own Escape is untouched.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="animate-slide-down"
|
||||
data-testid="url-toast"
|
||||
role="status"
|
||||
aria-atomic="true"
|
||||
aria-keyshortcuts="Control+Shift+O"
|
||||
onKeyDown={onKeyDown}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
@@ -56,7 +167,7 @@ export default function UrlToast({
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
maxWidth: "min(90%, 600px)",
|
||||
}}
|
||||
>
|
||||
@@ -69,6 +180,11 @@ export default function UrlToast({
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{" · "}
|
||||
<span data-testid="url-toast-shortcut" style={{ fontFamily: "monospace" }}>
|
||||
{URL_TOAST_SHORTCUT}
|
||||
</span>{" "}
|
||||
to reach the buttons, Esc to dismiss
|
||||
</div>
|
||||
<div
|
||||
data-testid="url-toast-url"
|
||||
@@ -109,79 +225,44 @@ export default function UrlToast({
|
||||
{rest}
|
||||
</span>
|
||||
</div>
|
||||
{signIn && (
|
||||
<div
|
||||
data-testid="url-toast-signin-hint"
|
||||
style={{
|
||||
marginTop: 3,
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
Sign-in link — the callback listener is inside the container.
|
||||
Opening it there closes the loop; the host browser needs the auth
|
||||
bridge.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent)")
|
||||
}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
{onOpenInContainer && (
|
||||
// A sign-in completed in the *container's* browser lands its callback
|
||||
// on the container's own loopback, which is where the tool waiting for
|
||||
// it is listening — no host round trip, no auth bridge.
|
||||
<button
|
||||
onClick={onOpenInContainer}
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
In container
|
||||
</button>
|
||||
{signIn ? (
|
||||
<>
|
||||
{containerButton}
|
||||
{hostButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hostButton}
|
||||
{containerButton}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
color: "var(--text-secondary)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-primary)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
aria-label="Dismiss"
|
||||
title="Dismiss (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import Modal from "./Modal";
|
||||
import { PaneVisibilityProvider } from "./PaneVisibility";
|
||||
import { dropIsBlocked } from "../../lib/dropTarget";
|
||||
|
||||
/**
|
||||
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
|
||||
@@ -102,6 +104,108 @@ describe("Modal", () => {
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stepping aside with the pane that owns it
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("marks its backdrop as swallowing native file drops", async () => {
|
||||
// `lib/dropTarget` refuses every drop in the window while a dialog is on
|
||||
// screen, and this marker is half of how it knows one is (the panel's
|
||||
// `aria-modal` is the other half).
|
||||
render(
|
||||
<Modal title="Reset" onClose={vi.fn()}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const backdrop = document.querySelector(".fixed.inset-0");
|
||||
expect(backdrop).toHaveAttribute("data-blocks-drop", "true");
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it("paints nothing, traps nothing and blocks no drop while its pane is hidden", async () => {
|
||||
// A dialog portals to `document.body`, where the `hidden` class its pane
|
||||
// uses to step aside for another tab cannot reach it. Left to itself it
|
||||
// stayed on screen over the tab the user switched to, kept its Escape
|
||||
// binding, and refused every native file drop in the window.
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PaneVisibilityProvider visible={false}>
|
||||
<Modal title="Reset" onClose={onClose}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
|
||||
const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement;
|
||||
expect(backdrop.hidden).toBe(true);
|
||||
expect(backdrop.style.display).toBe("none");
|
||||
expect(dropIsBlocked()).toBe(false);
|
||||
// Two independent reasons it does not block, because they are maintained
|
||||
// in two files: the marker is gone *and* `[hidden]` disqualifies it.
|
||||
expect(backdrop.hasAttribute("data-blocks-drop")).toBe(false);
|
||||
expect(backdrop.contains(document.activeElement)).toBe(false);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Back on screen: the same dialog, still mounted, resumes everything.
|
||||
// (Including the marker: it is dropped on hide, not written once at mount.)
|
||||
rerender(
|
||||
<PaneVisibilityProvider visible={true}>
|
||||
<Modal title="Reset" onClose={onClose}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
expect(backdrop.hidden).toBe(false);
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not leave focus inside itself when its pane steps aside", async () => {
|
||||
// The dialog goes `display:none` with the keyboard focus still inside it,
|
||||
// and nothing else relocates it — so the user arrives on the tab they
|
||||
// switched to with focus held by a dialog they cannot see, Tab resuming
|
||||
// from inside it. jsdom does not blur on `display:none` either, so this
|
||||
// is exactly the state the assertion below describes.
|
||||
const { rerender } = render(
|
||||
<PaneVisibilityProvider visible={true}>
|
||||
<Modal title="Reset" onClose={vi.fn()}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
const panel = screen.getByRole("dialog");
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
|
||||
rerender(
|
||||
<PaneVisibilityProvider visible={false}>
|
||||
<Modal title="Reset" onClose={vi.fn()}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
expect(panel.contains(document.activeElement)).toBe(false);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
// …and coming back puts it where it was: inside the dialog.
|
||||
rerender(
|
||||
<PaneVisibilityProvider visible={true}>
|
||||
<Modal title="Reset" onClose={vi.fn()}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores Escape and overlay clicks when not dismissible", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePaneVisible } from "./PaneVisibility";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
@@ -66,26 +67,51 @@ export default function Modal({
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
// A dialog portals to `document.body`, so the `hidden` class its pane uses to
|
||||
// step aside for another tab cannot reach it. `PaneVisibility` is how it
|
||||
// finds out, and while it is false this dialog paints nothing, traps
|
||||
// nothing, holds no focus, and blocks no native file drop.
|
||||
const paneVisible = usePaneVisible();
|
||||
const paneVisibleRef = useRef(paneVisible);
|
||||
paneVisibleRef.current = paneVisible;
|
||||
|
||||
// Remember what had focus, move focus inside, restore on unmount.
|
||||
// Remember what had focus, and restore it on unmount — but not if the pane
|
||||
// is hidden by then: a dialog closed while the user is on another tab would
|
||||
// otherwise yank focus back to a control they cannot see.
|
||||
useEffect(() => {
|
||||
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const panel = panelRef.current;
|
||||
if (panel) {
|
||||
const target =
|
||||
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||
requestAnimationFrame(() => target.focus?.());
|
||||
}
|
||||
return () => {
|
||||
restoreFocusRef.current?.focus?.();
|
||||
if (paneVisibleRef.current) restoreFocusRef.current?.focus?.();
|
||||
};
|
||||
// Mount/unmount only — re-running would steal focus mid-interaction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Escape closes; Tab is trapped inside the panel.
|
||||
// Move focus inside — on mount, and again whenever the pane comes back. And
|
||||
// move it *out* when the pane steps aside: the backdrop goes `display:none`
|
||||
// with the keyboard focus still inside it, and nothing else relocates it, so
|
||||
// the user lands on the new tab with focus held by a dialog they cannot see.
|
||||
// Blurring puts it on `<body>`, which is where a fresh Tab starts.
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
if (!paneVisible) {
|
||||
const active = panel.ownerDocument.activeElement as HTMLElement | null;
|
||||
if (active && panel.contains(active)) active.blur?.();
|
||||
return;
|
||||
}
|
||||
const target = initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||
const frame = requestAnimationFrame(() => target.focus?.());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// `initialFocusRef` is a ref object; re-running on its identity would steal
|
||||
// focus mid-interaction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneVisible]);
|
||||
|
||||
// Escape closes; Tab is trapped inside the panel. Neither applies while the
|
||||
// pane is hidden — those keystrokes belong to whatever the user is looking
|
||||
// at instead.
|
||||
useEffect(() => {
|
||||
if (!paneVisible) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && dismissible) {
|
||||
e.stopPropagation();
|
||||
@@ -119,7 +145,7 @@ export default function Modal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [dismissible, onClose]);
|
||||
}, [dismissible, onClose, paneVisible]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -133,6 +159,16 @@ export default function Modal({
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
/* This dialog swallows native file drops for as long as it is on screen.
|
||||
Dropped while the owning pane is hidden, so a dialog parked on another
|
||||
tab does not keep refusing drops here — `lib/dropTarget.ts` also
|
||||
filters `[hidden]`, and these two must not disagree. */
|
||||
data-blocks-drop={paneVisible ? "true" : undefined}
|
||||
hidden={!paneVisible}
|
||||
aria-hidden={paneVisible ? undefined : true}
|
||||
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the
|
||||
attribute alone loses. Inline wins over both. */
|
||||
style={paneVisible ? undefined : { display: "none" }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* "Is the pane I belong to the one on screen?"
|
||||
*
|
||||
* The main area keeps every tab *mounted* and hides the inactive ones with a
|
||||
* `hidden` class, so their state survives a tab switch. A `ui/Modal` opened
|
||||
* inside one of those panes does not go quiet when its pane does: it portals
|
||||
* to `document.body`, where an ancestor's `display:none` cannot reach it. So a
|
||||
* dialog opened in project A stayed painted over project B after a tab switch,
|
||||
* kept its focus trap and its Escape binding, and — because it is a blocking
|
||||
* overlay — refused every native file drop in the window.
|
||||
*
|
||||
* `App` publishes the answer around each pane it mounts — it is what decides
|
||||
* which one is on screen — and `Modal` reads it. Nothing else needs to:
|
||||
* dialogs are the only thing in the app that escapes its pane's subtree.
|
||||
*
|
||||
* Default `true`, so a dialog with no pane above it — host settings, the
|
||||
* Docker install prompt — behaves exactly as it always has.
|
||||
*/
|
||||
const PaneVisibilityContext = createContext(true);
|
||||
|
||||
export function PaneVisibilityProvider({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PaneVisibilityContext.Provider value={visible}>
|
||||
{children}
|
||||
</PaneVisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** True unless an ancestor pane says it is currently hidden. */
|
||||
export function usePaneVisible(): boolean {
|
||||
return useContext(PaneVisibilityContext);
|
||||
}
|
||||
@@ -47,7 +47,16 @@ function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
||||
{tone.glyph}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
|
||||
{/* Clamped. A toast message is normally a sentence, but some of them
|
||||
quote text a *container* wrote — and this card is `z-[60]`, above
|
||||
every modal, with its dismiss button at the top. An unclamped
|
||||
message of a few kilobytes is a card taller than the viewport whose
|
||||
✕ has been pushed off-screen, i.e. an unclosable overlay. The
|
||||
`detail` block below has always had `max-h-40 overflow-auto`; this
|
||||
half did not. */}
|
||||
<div className="text-[var(--text-primary)] break-words max-h-40 overflow-y-auto">
|
||||
{toast.message}
|
||||
</div>
|
||||
{toast.detail && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||
import { ANTHROPIC_SIGN_IN_HOSTS, extendsUrl, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||
import type {
|
||||
ClaudeTokenCodeRejectedEvent,
|
||||
ClaudeTokenLinkEvent,
|
||||
@@ -67,7 +67,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
* starts with it. That is the case longest-wins existed for — a repainting
|
||||
* TUI can land a truncated copy of the same link in the transcript before
|
||||
* the complete one — and it cannot swap the origin, because a longer string
|
||||
* with the same prefix has the same host.
|
||||
* with the same prefix has the same host. {@link extendsUrl} is that rule;
|
||||
* the terminal's URL prompt slot shares it.
|
||||
*/
|
||||
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||
const cleaned = candidates
|
||||
@@ -79,7 +80,7 @@ export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||
|
||||
let best: string | null = null;
|
||||
for (const url of pool) {
|
||||
if (best === null || url.startsWith(best)) best = url;
|
||||
if (best === null || extendsUrl(url, best)) best = url;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useFileManager } from "./useFileManager";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
readContainerFile: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Transient failures go to `ToastHost` rather than an inline string — see the
|
||||
* comment at the top of `useFileManager`. The store is mocked down to the one
|
||||
* method the hook reaches for.
|
||||
*/
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../store/appState", () => ({
|
||||
useAppState: { getState: () => ({ pushToast }) },
|
||||
}));
|
||||
|
||||
/** Everything the hook has said through the toast host, message and detail. */
|
||||
const toastText = () =>
|
||||
pushToast.mock.calls
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
name,
|
||||
path: `/workspace/${name}`,
|
||||
is_directory: false,
|
||||
is_symlink: false,
|
||||
size: 10,
|
||||
modified: "2024-01-01 00:00:00",
|
||||
permissions: "644",
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("useFileManager navigation", () => {
|
||||
it("lists a directory and remembers where it is", async () => {
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
expect(result.current.currentPath).toBe("/workspace/app");
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("surfaces a listing failure rather than showing a stale directory", async () => {
|
||||
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/root");
|
||||
});
|
||||
expect(result.current.error).toContain("Permission denied");
|
||||
expect(result.current.currentPath).toBe("/workspace");
|
||||
});
|
||||
|
||||
it("goes up one level, and stops at the root", async () => {
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app/src");
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.goUp();
|
||||
});
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("/workspace/app"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.navigate("/");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
result.current.goUp();
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager rename and mkdir", () => {
|
||||
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.renameEntry(file("a.txt"), " renamed.txt ");
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/a.txt", "renamed.txt");
|
||||
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the editor open and shows what the container said when a rename fails", async () => {
|
||||
// Renames outside /workspace legitimately fail; the user needs mv's words.
|
||||
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toastText()).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.renameEntry(file("a.txt"), "a.txt");
|
||||
});
|
||||
expect(renameContainerPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a folder under the current directory", async () => {
|
||||
createContainerDirectory.mockResolvedValue("/workspace/app/new");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.createFolder(" new ");
|
||||
});
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace/app", "new");
|
||||
});
|
||||
|
||||
it("surfaces a clash instead of silently doing nothing", async () => {
|
||||
createContainerDirectory.mockRejectedValue("mkdir: cannot create directory 'src': File exists");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.createFolder("src");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toastText()).toContain("File exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager stays where the user is", () => {
|
||||
it("does not drag the pane back when the user navigates away mid-operation", async () => {
|
||||
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
||||
// time the rename finishes. Re-listing the *captured* path is what used to
|
||||
// yank them out of the directory they had walked into.
|
||||
let failRename: (reason: unknown) => void = () => {};
|
||||
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||
// implementations, so a never-settling one would hang every test after it.
|
||||
renameContainerPath.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failRename = reject; }),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let rename!: Promise<boolean>;
|
||||
await act(async () => {
|
||||
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
listContainerFiles.mockResolvedValue([file("index.ts", { path: "/workspace/src/index.ts" })]);
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/src");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failRename("mv: no space left on device");
|
||||
await rename;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/src");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||
// No re-list of the directory the rename targeted…
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
// …and no failure text painted over the listing that replaced it.
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toastText()).toContain("no space left");
|
||||
});
|
||||
|
||||
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
||||
renameContainerPath.mockResolvedValue("/workspace/b.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
|
||||
it("lets the newest listing win when a slow one lands last", async () => {
|
||||
// Two listings in flight, and the slower one is not necessarily the older
|
||||
// one. Landing last used to set both the rows and the breadcrumb back.
|
||||
let landSlow: (entries: FileEntry[]) => void = () => {};
|
||||
listContainerFiles.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { landSlow = resolve; }),
|
||||
);
|
||||
listContainerFiles.mockResolvedValueOnce([file("new.txt")]);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let slow!: Promise<void>;
|
||||
await act(async () => {
|
||||
slow = result.current.navigate("/workspace/slow");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/fast");
|
||||
});
|
||||
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||
|
||||
await act(async () => {
|
||||
landSlow([file("stale.txt")]);
|
||||
await slow;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["new.txt"]);
|
||||
});
|
||||
|
||||
it("keeps a failed navigation from claiming the directory it never reached", async () => {
|
||||
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/root");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
// The pane never left /workspace, so a new folder made now lands there.
|
||||
await act(async () => {
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Refusals the backend already phrased for a person. `ToastHost` renders a
|
||||
* `detail` as collapsed monospace behind a "Details" button, so a sentence
|
||||
* reported that way is a sentence nobody reads.
|
||||
*/
|
||||
describe("useFileManager surfaces written refusals as prose", () => {
|
||||
const outsideRoots =
|
||||
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||
|
||||
/** The toast this operation pushed. */
|
||||
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
||||
|
||||
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
||||
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
expect(lastToast().detail).toBeUndefined();
|
||||
expect(lastToast().message).not.toMatch(/^Error:/);
|
||||
});
|
||||
|
||||
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
|
||||
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
});
|
||||
|
||||
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
||||
createContainerDirectory.mockRejectedValueOnce("no space left on device");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe('Could not create "new"');
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both of these actions are *dialog-driven from Rust* — the hook passes a
|
||||
* project and a directory and gets back an answer, and there is deliberately no
|
||||
* host path anywhere in this file. What is worth pinning is the vocabulary of
|
||||
* that answer, because two of its values look like failure and are not: `null`
|
||||
* means the user dismissed the picker, and `0` bytes means an empty file was
|
||||
* saved successfully.
|
||||
*/
|
||||
describe("useFileManager saving to the host", () => {
|
||||
it("treats a zero-byte save as a success", async () => {
|
||||
// The bug this exists for: `if (!bytes) return` reads a genuine
|
||||
// zero-length file — an empty `.gitkeep`, a truncated log — as a
|
||||
// dismissal, so the file lands on the host and the app says nothing at
|
||||
// all. The sentinel is `null`, and only `null`.
|
||||
downloadContainerFile.mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("empty.txt"));
|
||||
});
|
||||
expect(result.current.completed).toContain("empty.txt");
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says nothing at all when the dialog is dismissed", async () => {
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
expect(result.current.completed).toBeNull();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file in a refusal", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("secret.txt"));
|
||||
});
|
||||
expect(toastText()).toContain("secret.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager uploading from the host", () => {
|
||||
it("uploads into the directory on screen and shows the result", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
|
||||
failures: [],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
expect(result.current.completed).toContain("2 files");
|
||||
// The directory is named. `target` is captured at click time and the
|
||||
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
|
||||
// in front of a grid those files are not in.
|
||||
expect(result.current.completed).toContain("/workspace/app");
|
||||
// The new files are only on screen if the listing was asked for again.
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
});
|
||||
|
||||
it("reports every file that failed, not just a count", async () => {
|
||||
// "3 of 5 uploaded" without naming the two is not a report — the user
|
||||
// cannot tell which ones to retry, or why.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/ok.txt"],
|
||||
failures: [
|
||||
"/home/j/Pictures is a folder — upload its files individually.",
|
||||
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(2);
|
||||
expect(toastText()).toContain("is a folder");
|
||||
expect(toastText()).toContain("too large");
|
||||
// A partial batch still succeeded partially, and the pane must show it.
|
||||
expect(result.current.completed).toContain("1 file");
|
||||
});
|
||||
|
||||
it("does not refresh when the picker was dismissed", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
expect(result.current.completed).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a refusal that happened before the picker once, not per file", async () => {
|
||||
// No container, not running, or a directory this pane may not write to.
|
||||
// There is no selection yet, so there is nothing to enumerate.
|
||||
uploadFilesToContainer.mockRejectedValueOnce(
|
||||
"Start the project before uploading files — it runs inside the running container.",
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(1);
|
||||
expect(toastText()).toContain("Start the project");
|
||||
});
|
||||
|
||||
it("does not drag the pane back when the user navigated during the upload", async () => {
|
||||
// The same rule rename and new-folder follow: a slow operation must not
|
||||
// relist a directory the user has already left.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/other");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(result.current.currentPath).toBe("/workspace/other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager transfer state", () => {
|
||||
it("marks an upload in flight for as long as it runs", async () => {
|
||||
// Without this the button stays live: a second click opens a second OS
|
||||
// dialog and runs a second concurrent exec, and a slow transfer looks
|
||||
// exactly like a click that did nothing.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.uploading).toBe(false);
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
release({ uploaded: [], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("stays in flight through the refresh, not just the transfer", async () => {
|
||||
// Clearing the flag the moment the command settled put the button back
|
||||
// while the re-listing was still running, so a second click landed
|
||||
// mid-refresh on a grid that was still showing the old contents.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
let finishListing: (v: unknown) => void = () => {};
|
||||
listContainerFiles.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishListing = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The transfer is done; the listing it triggered is not.
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
finishListing([file("a.txt")]);
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the upload flag when the transfer fails", async () => {
|
||||
// The `catch` returns early, so without a `finally` the button is disabled
|
||||
// for the rest of the session — the failure mode is a pane that can never
|
||||
// upload again, with no error left on screen to explain it.
|
||||
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks each save separately, so one finishing does not free another", async () => {
|
||||
// The bug this exists for: `savingPath` was a single string. Starting a
|
||||
// second save overwrote it, so the first row went live again mid-transfer,
|
||||
// and whichever save settled first cleared the flag for both — dismissing
|
||||
// the second dialog was enough. A set is what the design needs, because
|
||||
// "only the row being saved is disabled" is exactly what makes a second
|
||||
// save startable.
|
||||
let releaseBig: (v: unknown) => void = () => {};
|
||||
let releaseSmall: (v: unknown) => void = () => {};
|
||||
downloadContainerFile
|
||||
.mockReturnValueOnce(new Promise((r) => { releaseBig = r; }))
|
||||
.mockReturnValueOnce(new Promise((r) => { releaseSmall = r; }));
|
||||
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let big: Promise<void>;
|
||||
let small: Promise<void>;
|
||||
act(() => { big = result.current.saveToHost(file("big.bin")); });
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
|
||||
act(() => { small = result.current.saveToHost(file("notes.txt")); });
|
||||
// Both, at once — a scalar could only hold the second.
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(true);
|
||||
|
||||
// The second one finishing must not re-enable the first, which is still
|
||||
// streaming. `null` is the dismissal path, which is how this was cheapest
|
||||
// to trigger in practice.
|
||||
await act(async () => { releaseSmall(null); await small; });
|
||||
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(false);
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
|
||||
await act(async () => { releaseBig(10); await big; });
|
||||
expect(result.current.savingPaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears a row's saving flag when its save fails", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("b.txt"));
|
||||
});
|
||||
expect(result.current.savingPaths.size).toBe(0);
|
||||
});
|
||||
});
|
||||
+231
-25
@@ -1,74 +1,280 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* ## Where failures are reported
|
||||
*
|
||||
* Two audiences, two places, and the split is deliberate.
|
||||
*
|
||||
* The **initial listing** failure stays in `error`, rendered inline above the
|
||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — rename, create folder, upload, save
|
||||
* to host — goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
|
||||
* does not scroll away.
|
||||
*
|
||||
* ## Where the current directory lives
|
||||
*
|
||||
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
||||
* after an await). Every long operation captures the directory it targets at
|
||||
* the start and compares it against the ref at the end: a slow rename in
|
||||
* `/workspace` must not drag the pane back out of `src/` because that is where
|
||||
* the closure happened to be created. The ref moves at the *start* of a
|
||||
* navigation rather than when the listing lands, because the question being
|
||||
* asked is "where is the user going", not "what is on screen right now" — and
|
||||
* it is put back if that navigation fails.
|
||||
*/
|
||||
export function useFileManager(projectId: string) {
|
||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/**
|
||||
* What just finished, for the live region — a rename or a new folder is a
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
/**
|
||||
* Which host transfers are in flight.
|
||||
*
|
||||
* Both actions open an OS dialog and can then run for a long time on a large
|
||||
* file, with nothing on screen to say so. Without this the buttons stay live:
|
||||
* a second click opens a second dialog and runs a second concurrent exec
|
||||
* against the same file, and a multi-gigabyte save is indistinguishable from
|
||||
* a click that did nothing.
|
||||
*
|
||||
* `savingPaths` is a **set**, not one path. Keeping only the row being
|
||||
* disabled is what makes the pane usable during a big transfer — and that is
|
||||
* precisely what makes a *second* save startable, so the state has to be able
|
||||
* to hold two. As a scalar it could not: starting a save on `notes.txt` while
|
||||
* `big.bin` was still streaming overwrote it, so `big.bin`'s button went live
|
||||
* again mid-transfer; and whichever save finished first cleared the flag for
|
||||
* both. Dismissing the second dialog was enough to do it.
|
||||
*
|
||||
* Paths are unique within a listing, so a path is a usable key — `FilesTab`
|
||||
* relies on the same fact for its row keys.
|
||||
*/
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingPaths, setSavingPaths] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
/**
|
||||
* A slow listing can land after a newer one and set both the rows and the
|
||||
* breadcrumb back to a directory the user already left. Same generation
|
||||
* guard `useContainerMigration` uses: every async write
|
||||
* checks it is still the newest before it lands.
|
||||
*/
|
||||
const navGeneration = useRef(0);
|
||||
|
||||
/**
|
||||
* Report a failed operation, given the headline this hook would write and the
|
||||
* raw failure behind it.
|
||||
*
|
||||
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
||||
* category, not an explanation. Some backend refusals are already a finished
|
||||
* sentence written for the person reading it — a container path outside the
|
||||
* roots this panel may change, a name it will not create — and those used to
|
||||
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
|
||||
* monospace behind a "Details" button. So the sentence that said what was
|
||||
* wrong and what to do about it was hidden under a headline that said
|
||||
* neither. When there is such a sentence it becomes the headline, and there
|
||||
* is nothing left to hide.
|
||||
*/
|
||||
const report = useCallback((message: string, cause: unknown) => {
|
||||
const promoted = readableRefusal(cause);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: promoted ?? message,
|
||||
detail: promoted ? undefined : errorText(cause),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback(
|
||||
async (path: string) => {
|
||||
const mine = ++navGeneration.current;
|
||||
const previous = currentPathRef.current;
|
||||
currentPathRef.current = path;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.listContainerFiles(projectId, path);
|
||||
if (navGeneration.current !== mine) return;
|
||||
setEntries(result);
|
||||
setCurrentPath(path);
|
||||
} catch (e) {
|
||||
if (navGeneration.current !== mine) return;
|
||||
// The move did not happen, so the pane is still where it was — the ref
|
||||
// has to agree with the breadcrumb or the next operation will decide
|
||||
// it targeted a directory nobody is looking at.
|
||||
currentPathRef.current = previous;
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (navGeneration.current === mine) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||
const here = currentPathRef.current;
|
||||
if (here === "/") return;
|
||||
const parent = here.replace(/\/[^/]+$/, "") || "/";
|
||||
navigate(parent);
|
||||
}, [currentPath, navigate]);
|
||||
}, [navigate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
navigate(currentPath);
|
||||
}, [currentPath, navigate]);
|
||||
navigate(currentPathRef.current);
|
||||
}, [navigate]);
|
||||
|
||||
const downloadFile = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
/**
|
||||
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
||||
* `/` in it, so this can never turn into a move. Resolves true on success so
|
||||
* the caller knows whether to leave edit mode.
|
||||
*/
|
||||
const renameEntry = useCallback(
|
||||
async (entry: FileEntry, newName: string) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed || trimmed === entry.name) return true;
|
||||
const target = currentPathRef.current;
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
await commands.renameContainerPath(projectId, entry.path, trimmed);
|
||||
setCompleted(`Renamed "${entry.name}" to "${trimmed}".`);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report(`Could not rename "${entry.name}"`, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
const createFolder = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return true;
|
||||
const target = currentPathRef.current;
|
||||
try {
|
||||
await commands.createContainerDirectory(projectId, target, trimmed);
|
||||
setCompleted(`Created "${trimmed}".`);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
report(`Could not create "${trimmed}"`, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy host files into the directory on screen.
|
||||
*
|
||||
* The picker is opened by **Rust**, not here — `upload_files_to_container`
|
||||
* shows it, reads what the user chose and never lets a host path near IPC.
|
||||
* So this passes a directory and gets back an outcome; `null` means the user
|
||||
* dismissed the dialog, which is not a failure and says nothing.
|
||||
*
|
||||
* One dialog can select several files and they need not agree, hence two
|
||||
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
|
||||
* which two is not a report. The listing is refreshed once, at the end, and
|
||||
* only if the user is still looking at the directory that was targeted.
|
||||
*/
|
||||
const uploadFiles = useCallback(async () => {
|
||||
const target = currentPathRef.current;
|
||||
setUploading(true);
|
||||
try {
|
||||
const selected = await openDialog({ multiple: false, directory: false });
|
||||
if (!selected) return;
|
||||
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
|
||||
await navigate(currentPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await commands.uploadFilesToContainer(projectId, target);
|
||||
} catch (e) {
|
||||
// A failure *before* the picker: no container, not running, or a
|
||||
// directory this pane may not write to. One toast, not one per file.
|
||||
report("Could not upload", e);
|
||||
return;
|
||||
}
|
||||
if (!outcome) return;
|
||||
for (const failure of outcome.failures) {
|
||||
useAppState.getState().pushToast({ kind: "error", message: failure });
|
||||
}
|
||||
if (outcome.uploaded.length === 0) return;
|
||||
// The directory is named, not implied. `target` is captured at click time
|
||||
// and the picker is a modal OS dialog — the user has all the time in the
|
||||
// world to browse somewhere else while it is open, and the files land
|
||||
// where they started. "Uploaded 2 files." in front of a grid that does
|
||||
// not contain them is a worse answer than no message at all.
|
||||
const count = outcome.uploaded.length;
|
||||
setCompleted(
|
||||
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
||||
);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
} finally {
|
||||
// Around the *whole* body, refresh included. Clearing it the moment the
|
||||
// command settled put the button back before the re-listing had run, so
|
||||
// a second click landed mid-refresh on a grid that was still the old one.
|
||||
setUploading(false);
|
||||
}
|
||||
}, [projectId, currentPath, navigate]);
|
||||
}, [projectId, navigate, report]);
|
||||
|
||||
/**
|
||||
* Save one file out to the host, with Rust opening the save dialog.
|
||||
*
|
||||
* No refresh: nothing in the container changed. The save dialog is also what
|
||||
* asks about overwriting an existing host file, which is why the backend has
|
||||
* no collision handling of its own to get wrong. `null` is a dismissal.
|
||||
*/
|
||||
const saveToHost = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
setSavingPaths((live) => new Set(live).add(entry.path));
|
||||
try {
|
||||
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
||||
// `0` is a real answer — an empty file saved is a success — so this
|
||||
// tests for the dismissal sentinel, not for falsiness.
|
||||
if (bytes === null) return;
|
||||
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}"`, e);
|
||||
} finally {
|
||||
// Remove only this one. A save that finishes while another is still
|
||||
// streaming must not re-enable the other's row.
|
||||
setSavingPaths((live) => {
|
||||
const next = new Set(live);
|
||||
next.delete(entry.path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, report],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
/** Inline, in-context: why the listing on screen is empty. */
|
||||
error,
|
||||
/** What the last operation finished doing, for the live region. */
|
||||
completed,
|
||||
setError,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
/** A host transfer is in flight — see the state declarations above. */
|
||||
uploading,
|
||||
savingPaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useState } from "react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
@@ -27,13 +29,14 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (label: string, fn: () => Promise<unknown>) => {
|
||||
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
setBusy(true);
|
||||
setContainerProgress(project.id, null);
|
||||
try {
|
||||
await fn();
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
fail(`${label} failed for “${project.name}”`, e);
|
||||
return undefined;
|
||||
} finally {
|
||||
setContainerProgress(project.id, null);
|
||||
setBusy(false);
|
||||
@@ -53,8 +56,25 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => run("Reset", () => rebuild(project.id)),
|
||||
[run, rebuild, project.id],
|
||||
() =>
|
||||
run("Reset", async () => {
|
||||
const outcome = await rebuild(project.id);
|
||||
if (outcome.leftover_image || outcome.leftover_volumes.length > 0) {
|
||||
// Not "run `docker volume rm`" — by the time this renders, the new
|
||||
// container this same call just started already has the leftover
|
||||
// volume mounted, so that command would just hit the same 409
|
||||
// Reset did. Stopping the project first is what actually frees it.
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Reset for “${project.name}” did not fully clean up`,
|
||||
detail: `Triple-C could not remove ${describeResetLeftovers(outcome)} from before the reset, so \
|
||||
the new container may still be built from, or contain, old data. Stop the project, then try \
|
||||
Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}),
|
||||
[run, rebuild, project.id, project.name, pushToast],
|
||||
);
|
||||
|
||||
const openClaudeTerminal = useCallback(async () => {
|
||||
@@ -122,10 +142,15 @@ export function useProjectActions(project: Project) {
|
||||
if (!hostPath) return;
|
||||
setBackingUp(true);
|
||||
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||
// `binary` matches what the host's file browser will say about the
|
||||
// tarball this just wrote. The unit is part of the formatted string, so
|
||||
// there is no separate " MB" to append — and unlike the inline
|
||||
// `toFixed(1)` this replaced, a multi-gigabyte backup no longer reports
|
||||
// itself as a five-digit number of megabytes.
|
||||
const size = formatBytes(bytes, { binary: true });
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Backup saved (${mb} MB).`,
|
||||
message: `Backup saved (${size}).`,
|
||||
detail:
|
||||
"Includes Claude config — may contain API keys. Keep the archive private.",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { Project, ProjectStatus } from "../lib/types";
|
||||
|
||||
const startProjectContainer = vi.fn();
|
||||
const stopProjectContainer = vi.fn();
|
||||
const rebuildProjectContainer = vi.fn();
|
||||
const listProjects = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
startProjectContainer: (id: string) => startProjectContainer(id),
|
||||
stopProjectContainer: (id: string) => stopProjectContainer(id),
|
||||
rebuildProjectContainer: (id: string) => rebuildProjectContainer(id),
|
||||
listProjects: () => listProjects(),
|
||||
addProject: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
const project = (status: ProjectStatus): Project =>
|
||||
({ id: "p1", name: "whp", status }) as unknown as Project;
|
||||
|
||||
/** The status the sidebar row and Project Home both read. */
|
||||
const statusOf = () => useAppState.getState().projects.find((p) => p.id === "p1")?.status;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({ projects: [project("running")] });
|
||||
});
|
||||
|
||||
/**
|
||||
* The optimistic status is what makes a click move the row immediately. It is
|
||||
* also what strands the row when the command it was betting on never ran.
|
||||
*
|
||||
* Since every lifecycle command started taking the per-project lock and failing
|
||||
* fast, all three can be refused *before* the backend changes anything — and
|
||||
* `stop` could not fail this way at all before, because it took no exclusion.
|
||||
* `isTransitioning` disables both Start and Stop, and the only thing that
|
||||
* clears it is `reconcileProjectStatuses`, which runs once when Docker first
|
||||
* appears. So a stale optimistic status is not a cosmetic problem: it is a row
|
||||
* the user cannot operate again until the app is restarted.
|
||||
*/
|
||||
describe("useProjects puts the status back when a refused command never ran", () => {
|
||||
const refusal =
|
||||
"This project's snapshot is being compacted. Wait for it to finish before starting or recreating its container.";
|
||||
|
||||
it("does not leave a refused Stop showing 'stopping' forever", async () => {
|
||||
stopProjectContainer.mockRejectedValue(refusal);
|
||||
// The lock is taken before `update_status`, so the backend still holds the
|
||||
// truth — which is why re-reading it is the correction, not a guess.
|
||||
listProjects.mockResolvedValue([project("running")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.stop("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("does not leave a refused Start showing 'starting' forever", async () => {
|
||||
useAppState.setState({ projects: [project("stopped")] });
|
||||
startProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockResolvedValue([project("stopped")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.start("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
|
||||
it("does not leave a refused Reset showing 'starting' forever", async () => {
|
||||
rebuildProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockResolvedValue([project("running")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.rebuild("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("falls back to what was on screen when even the re-read fails", async () => {
|
||||
// Two failures in a row must not land on the one state there is no way out
|
||||
// of. The backend's answer is preferred, but "unknown" is never a reason to
|
||||
// keep showing a transition that is not happening.
|
||||
stopProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockRejectedValue("Docker is not running");
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.stop("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("prefers the backend's answer over the status it captured", async () => {
|
||||
// A start that dies half-way really has changed the world, so the captured
|
||||
// status would be a lie. `listProjects` is the thing that knows.
|
||||
useAppState.setState({ projects: [project("stopped")] });
|
||||
startProjectContainer.mockRejectedValue("container exited during startup");
|
||||
listProjects.mockResolvedValue([project("error")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.start("p1")).rejects.toBe(
|
||||
"container exited during startup",
|
||||
);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("error");
|
||||
});
|
||||
|
||||
it("still paints the optimistic status on the way in", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
stopProjectContainer.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
listProjects.mockResolvedValue([project("stopped")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
let pending: Promise<void> | undefined;
|
||||
act(() => {
|
||||
pending = result.current.stop("p1");
|
||||
});
|
||||
expect(statusOf()).toBe("stopping");
|
||||
|
||||
await act(async () => {
|
||||
release?.();
|
||||
await pending;
|
||||
});
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useProjects.rebuild on success", () => {
|
||||
it("puts the outcome's project, not the whole outcome, into the list", async () => {
|
||||
const rebuilt = project("running");
|
||||
rebuildProjectContainer.mockResolvedValue({
|
||||
project: rebuilt,
|
||||
leftover_image: null,
|
||||
leftover_volumes: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.rebuild>>;
|
||||
await act(async () => {
|
||||
outcome = await result.current.rebuild("p1");
|
||||
});
|
||||
|
||||
// A regression here would put the `{ project, leftover_image,
|
||||
// leftover_volumes }` wrapper into the projects list instead of the
|
||||
// `Project` it wraps — a shape mismatch `tsc` would not catch inside a
|
||||
// callback typed to take `unknown` per Tauri's `invoke`.
|
||||
expect(useAppState.getState().projects.find((p) => p.id === "p1")).toEqual(rebuilt);
|
||||
expect(outcome.leftover_volumes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type { ProjectPath } from "../lib/types";
|
||||
import type { ProjectPath, ProjectStatus } from "../lib/types";
|
||||
|
||||
export function useProjects() {
|
||||
const {
|
||||
@@ -44,8 +44,9 @@ export function useProjects() {
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.removeProject(id);
|
||||
const report = await commands.removeProject(id);
|
||||
removeProjectFromList(id);
|
||||
return report;
|
||||
},
|
||||
[removeProjectFromList],
|
||||
);
|
||||
@@ -61,34 +62,85 @@ export function useProjects() {
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
/**
|
||||
* Paint the optimistic status, run the command, and **put the status back if
|
||||
* the command never happened.**
|
||||
*
|
||||
* The optimistic write exists so a click moves the row immediately. It used
|
||||
* to be safe to leave in place on failure, because the only way these three
|
||||
* commands could fail was after the backend had already started changing
|
||||
* things — so a stale "starting" was at worst premature.
|
||||
*
|
||||
* That stopped being true when every lifecycle command started taking the
|
||||
* per-project lock and failing fast: a compaction, a reset or another start
|
||||
* holding the project now refuses `start`, `stop` **and** `rebuild` before
|
||||
* one byte of state changes. `stop` in particular could not fail this way at
|
||||
* all before — it took no exclusion. The optimistic paint then has nothing to
|
||||
* become, and `isTransitioning` disables both Start and Stop, so the row is
|
||||
* stuck: the only thing that clears it is `reconcileProjectStatuses`, which
|
||||
* runs once, from `App.tsx`, when Docker first appears. A restart.
|
||||
*
|
||||
* Re-reading the list is preferred over restoring what was on screen, because
|
||||
* a refusal is not the only way these throw — a start that dies half-way
|
||||
* really has changed the world, and `listProjects` is the thing that knows.
|
||||
* The captured status is only the fallback for when that call fails too:
|
||||
* leaving the row transitioning is the one outcome the user cannot get out
|
||||
* of, so it must not be what a second failure lands on.
|
||||
*/
|
||||
const withOptimisticStatus = useCallback(
|
||||
async <T,>(
|
||||
id: string,
|
||||
status: "starting" | "stopping",
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const previous: ProjectStatus | null =
|
||||
useAppState.getState().projects.find((p) => p.id === id)?.status ?? null;
|
||||
setOptimisticStatus(id, status);
|
||||
try {
|
||||
return await run();
|
||||
} catch (e) {
|
||||
try {
|
||||
setProjects(await commands.listProjects());
|
||||
} catch {
|
||||
const project = useAppState.getState().projects.find((p) => p.id === id);
|
||||
if (project && previous) updateProjectInList({ ...project, status: previous });
|
||||
}
|
||||
// Rethrown unchanged: `useProjectActions` is what turns this into a
|
||||
// toast, and it must still see the original failure.
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
[setOptimisticStatus, setProjects, updateProjectInList],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "stopping");
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
},
|
||||
[setProjects, setOptimisticStatus],
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "stopping", async () => {
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
}),
|
||||
[setProjects, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const outcome = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(outcome.project);
|
||||
return outcome;
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSecretField, withoutUntouchedSecrets } from "./useSecretField";
|
||||
|
||||
describe("useSecretField", () => {
|
||||
it("says nothing about a secret the user never touched", () => {
|
||||
// The bug this exists for: the input always renders empty (secrets are
|
||||
// never serialized to the frontend), so the obvious `value || null` blur
|
||||
// handler sent `null` — which means *delete* — merely because the user
|
||||
// focused the field and tabbed away. No warning, nothing to undo.
|
||||
const { result } = renderHook(() => useSecretField("p1"));
|
||||
expect(result.current.value).toBe("");
|
||||
expect(result.current.edited).toBe(false);
|
||||
expect(result.current.patch("git_token")).toEqual({});
|
||||
// Absent, not null: `JSON.stringify` drops the key entirely, and Rust
|
||||
// distinguishes an absent key ("leave it") from an explicit null ("clear").
|
||||
expect("git_token" in result.current.patch("git_token")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the value once the user types", () => {
|
||||
const { result } = renderHook(() => useSecretField("p1"));
|
||||
act(() => result.current.setValue("ghp_secret"));
|
||||
expect(result.current.patch("git_token")).toEqual({ git_token: "ghp_secret" });
|
||||
});
|
||||
|
||||
it("sends null only when the user cleared a field they had typed in", () => {
|
||||
// This is the one case where deleting the stored secret is what was asked
|
||||
// for, and it has to keep working — the previous behaviour skipped `None`
|
||||
// entirely, so a blanked token was never actually revoked.
|
||||
const { result } = renderHook(() => useSecretField("p1"));
|
||||
act(() => result.current.setValue("typed"));
|
||||
act(() => result.current.setValue(""));
|
||||
expect(result.current.edited).toBe(true);
|
||||
expect(result.current.patch("git_token")).toEqual({ git_token: null });
|
||||
});
|
||||
|
||||
it("forgets a half-typed secret when the editor moves to another project", () => {
|
||||
const { result, rerender } = renderHook(({ id }) => useSecretField(id), {
|
||||
initialProps: { id: "p1" },
|
||||
});
|
||||
act(() => result.current.setValue("for-project-one"));
|
||||
rerender({ id: "p2" });
|
||||
expect(result.current.value).toBe("");
|
||||
expect(result.current.edited).toBe(false);
|
||||
expect(result.current.patch("api_key")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutUntouchedSecrets", () => {
|
||||
it("drops a secret key the caller did not set", () => {
|
||||
// `saveBedrock` spreads `{ ...bedrock, ...patch }`, and when `bedrock`
|
||||
// falls back to DEFAULT_BEDROCK_CONFIG that literal spells every secret out
|
||||
// as `null`. Without this filter, editing the AWS *region* would delete the
|
||||
// stored credentials as a side effect.
|
||||
const merged = {
|
||||
aws_region: "eu-west-1",
|
||||
aws_access_key_id: null,
|
||||
aws_secret_access_key: null,
|
||||
};
|
||||
const out = withoutUntouchedSecrets(merged, { aws_region: "eu-west-1" }, [
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
]);
|
||||
expect(out).toEqual({ aws_region: "eu-west-1" });
|
||||
expect("aws_access_key_id" in out).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a secret key the caller set, including an explicit null", () => {
|
||||
const merged = { aws_region: "eu-west-1", aws_access_key_id: null };
|
||||
const out = withoutUntouchedSecrets(
|
||||
merged,
|
||||
{ aws_access_key_id: null },
|
||||
["aws_access_key_id"],
|
||||
);
|
||||
expect(out).toEqual({ aws_region: "eu-west-1", aws_access_key_id: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* A password input whose stored value the frontend can never see.
|
||||
*
|
||||
* Secrets live in the OS keychain and are `#[serde(skip_serializing)]`, so a
|
||||
* `Project` arriving from Rust has no key for them at all and the input always
|
||||
* renders empty — whether or not a credential is stored. That is fine on its
|
||||
* own. What is not fine is the obvious blur handler:
|
||||
*
|
||||
* ```tsx
|
||||
* onBlur={() => save({ git_token: gitToken || null })}
|
||||
* ```
|
||||
*
|
||||
* An empty box sends `null`, and `null` now means **delete** (it used to mean
|
||||
* "skip", which was its own bug — a blanked token was never actually revoked).
|
||||
* So merely focusing a secret field and tabbing away destroyed the stored
|
||||
* credential, with nothing shown and nothing to undo it.
|
||||
*
|
||||
* The rule this encodes: **only a field the user actually typed in may speak
|
||||
* about a secret.** `patch()` returns `undefined` until then, and `undefined`
|
||||
* is dropped by `JSON.stringify`, so the key never reaches Rust — which
|
||||
* deliberately distinguishes an absent key ("leave it alone") from an explicit
|
||||
* `null` ("clear it"). See `explicitly_cleared_secrets` in
|
||||
* `commands/project_commands.rs`.
|
||||
*/
|
||||
export interface SecretField {
|
||||
/** Current input value. Always starts empty for a stored secret. */
|
||||
value: string;
|
||||
/** Whether the user has typed in this field since it was last reset. */
|
||||
edited: boolean;
|
||||
/** `onChange` handler — marks the field edited. */
|
||||
setValue: (next: string) => void;
|
||||
/**
|
||||
* What to put in the save patch, spread into it:
|
||||
* `save({ ...token.patch("git_token") })`.
|
||||
*
|
||||
* Empty when untouched, so the key is absent and the stored secret stands.
|
||||
*/
|
||||
patch: <K extends string>(key: K) => Partial<Record<K, string | null>>;
|
||||
}
|
||||
|
||||
export function useSecretField(projectId: string): SecretField {
|
||||
const [value, setValueRaw] = useState("");
|
||||
const [edited, setEdited] = useState(false);
|
||||
// Reset when the editor moves to a different project, so a value typed for
|
||||
// one project can never be saved onto another.
|
||||
const lastProject = useRef(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastProject.current !== projectId) {
|
||||
lastProject.current = projectId;
|
||||
setValueRaw("");
|
||||
setEdited(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const setValue = useCallback((next: string) => {
|
||||
setValueRaw(next);
|
||||
setEdited(true);
|
||||
}, []);
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends string>(key: K): Partial<Record<K, string | null>> =>
|
||||
edited ? ({ [key]: value || null } as Partial<Record<K, string | null>>) : {},
|
||||
[edited, value],
|
||||
);
|
||||
|
||||
return { value, edited, setValue, patch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop secret keys the caller did not explicitly set.
|
||||
*
|
||||
* The config editors save by spreading — `save({ bedrock_config: { ...bedrock,
|
||||
* ...patch } })`. That is safe while `bedrock` comes from Rust, because secrets
|
||||
* are never serialized and the keys are simply absent. It stops being safe the
|
||||
* moment the spread falls back to a `DEFAULT_*_CONFIG` literal, because those
|
||||
* spell every secret out as `null` — and `null` means delete. Editing the AWS
|
||||
* region would then wipe the stored credentials as a side effect.
|
||||
*
|
||||
* So the merged object is filtered: a secret key survives only if it is in the
|
||||
* caller's own patch, which is to say only if a `useSecretField` that the user
|
||||
* typed into put it there.
|
||||
*/
|
||||
export function withoutUntouchedSecrets<T extends object>(
|
||||
merged: T,
|
||||
patch: Partial<T>,
|
||||
secretKeys: readonly (keyof T)[],
|
||||
): T {
|
||||
const out = { ...merged };
|
||||
for (const key of secretKeys) {
|
||||
if (!(key in patch)) delete out[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget";
|
||||
|
||||
function pane(rect: Partial<DOMRect>): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect,
|
||||
}) as DOMRect;
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* **jsdom has no `elementFromPoint`.** That single fact is how a z-order gate
|
||||
* that refused every drop under a button, a toast and a dialog shipped green
|
||||
* through 81 drop tests: the branch was never entered, in any of them.
|
||||
*
|
||||
* The gate no longer asks a per-point question at all, so the gap can no
|
||||
* longer hide anything here — but the tests below still *install* an
|
||||
* `elementFromPoint` and hand it the most misleading answer available, because
|
||||
* "the module ignores it" is now a property worth pinning. `spy.mock.calls`
|
||||
* proves it directly.
|
||||
*/
|
||||
function stubElementFromPoint(top: Element | null): ReturnType<typeof vi.fn> {
|
||||
const spy = vi.fn(() => top);
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: spy,
|
||||
});
|
||||
return spy;
|
||||
}
|
||||
|
||||
function removeElementFromPoint(): void {
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
}
|
||||
|
||||
/** The DOM `ui/Modal` really renders: a marked backdrop around the dialog. */
|
||||
function openModal(): { backdrop: HTMLElement; panel: HTMLElement; button: HTMLElement } {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
const button = document.createElement("button");
|
||||
panel.appendChild(button);
|
||||
backdrop.appendChild(panel);
|
||||
document.body.appendChild(backdrop);
|
||||
return { backdrop, panel, button };
|
||||
}
|
||||
|
||||
function withUserAgent(ua: string): void {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: ua,
|
||||
});
|
||||
}
|
||||
|
||||
const REAL_UA = window.navigator.userAgent;
|
||||
const REAL_DPR = window.devicePixelRatio;
|
||||
|
||||
function withDevicePixelRatio(value: number): void {
|
||||
Object.defineProperty(window, "devicePixelRatio", { configurable: true, value });
|
||||
}
|
||||
|
||||
describe("dropTarget", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
removeElementFromPoint();
|
||||
withUserAgent(REAL_UA);
|
||||
withDevicePixelRatio(REAL_DPR);
|
||||
});
|
||||
|
||||
it("accepts a point inside the pane", () => {
|
||||
expect(isDropTarget(pane({}), { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a point outside the pane", () => {
|
||||
expect(isDropTarget(pane({}), { x: 400, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("converts physical pixels to CSS pixels", () => {
|
||||
const el = pane({});
|
||||
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 2 })).toBe(true);
|
||||
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a hidden pane, which has a zero-size rect", () => {
|
||||
const el = pane({ right: 0, bottom: 0, width: 0, height: 0 });
|
||||
expect(isDropTarget(el, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects every drop while a modal is open", () => {
|
||||
const el = pane({});
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
|
||||
dialog.remove();
|
||||
expect(dropIsBlocked()).toBe(false);
|
||||
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects every drop while a blocking overlay is up", () => {
|
||||
const el = pane({});
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(overlay);
|
||||
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a null pane", () => {
|
||||
expect(isDropTarget(null, { x: 1, y: 1 }, { devicePixelRatio: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The gate itself: document-wide, and provably not geometric
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("the blocking gate is document-wide, with no z-order in it", () => {
|
||||
it("never consults elementFromPoint, however tempting its answer", () => {
|
||||
// Round 2 asked `elementFromPoint` whether a *blocker* was painted at
|
||||
// the drop point, and trusted the answer absolutely. Anything painted
|
||||
// above the `z-50` backdrop in the same stacking context — `ToastHost`
|
||||
// at `z-[60]`, `TerminalContextMenu` at `z-[60]` — answered "no blocker
|
||||
// here" on a point the dialog was covering. The fix is not a better
|
||||
// answer, it is not asking: this pins that the call is gone, so a
|
||||
// future edit that reintroduces it fails here rather than in the wild.
|
||||
const el = pane({});
|
||||
const child = document.createElement("span");
|
||||
el.appendChild(child);
|
||||
const spy = stubElementFromPoint(child);
|
||||
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a covered drop even when a toast is painted over the dialog", () => {
|
||||
// C1, exactly. A refused drop pushes a toast; `ToastHost` is
|
||||
// `fixed bottom-4 right-4 z-[60]` and an error card stays until
|
||||
// dismissed; the *next* drop released on that card had a topmost
|
||||
// element with no blocker in it, and landed in the directory the dialog
|
||||
// was covering. The gate had armed its own hole.
|
||||
const el = pane({});
|
||||
openModal(); // z-50 backdrop, covering the pane
|
||||
const toastCard = document.createElement("div"); // z-[60], over the backdrop
|
||||
document.body.appendChild(toastCard);
|
||||
const spy = stubElementFromPoint(toastCard);
|
||||
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a drop anywhere in the window while a dialog is open", () => {
|
||||
// Deliberately stricter than "the points the dialog covers". A dialog
|
||||
// is a state the user entered on purpose and leaves with Escape, the
|
||||
// refusal is announced, and nothing is written — whereas being precise
|
||||
// about coverage has silently uploaded into a covered directory twice.
|
||||
const el = pane({});
|
||||
const { backdrop } = openModal();
|
||||
stubElementFromPoint(document.createElement("div")); // "nothing here"
|
||||
|
||||
expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
|
||||
backdrop.remove();
|
||||
expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
});
|
||||
|
||||
it("refuses under the shutdown overlay, which is not a dialog", () => {
|
||||
const el = pane({});
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(overlay);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
});
|
||||
|
||||
it("still refuses a dialog built without ui/Modal's marked backdrop", () => {
|
||||
// Only `aria-modal` is needed; `ui/Modal` is the supported route, but a
|
||||
// hand-rolled dialog must not be a hole either.
|
||||
const el = pane({});
|
||||
const backdrop = document.createElement("div");
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
backdrop.appendChild(panel);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
});
|
||||
|
||||
it("counts a blocker that is only decoratively hidden from assistive tech", () => {
|
||||
// `aria-hidden="true"` used to disqualify a blocker, and it is not a
|
||||
// visibility statement — `ui/Modal`'s own ✕ glyph carries it while
|
||||
// perfectly visible. A blocker nested inside such a wrapper would have
|
||||
// silently stopped blocking. Only `[hidden]` counts now.
|
||||
const el = pane({});
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.setAttribute("aria-hidden", "true");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-blocks-drop", "true");
|
||||
wrapper.appendChild(overlay);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
});
|
||||
|
||||
it("ignores a blocker whose pane has stepped aside", () => {
|
||||
// `ui/Modal` marks itself `hidden` when the tab that owns it is not the
|
||||
// one on screen. A dialog left open in project A must not keep refusing
|
||||
// drops in project B — this is the one case where over-refusal would be
|
||||
// unbounded, since the user cannot see the dialog to close it.
|
||||
const el = pane({});
|
||||
const { backdrop } = openModal();
|
||||
backdrop.setAttribute("hidden", "");
|
||||
|
||||
expect(dropIsBlocked()).toBe(false);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Round 1's shape: chrome painted over a pane must never refuse a drop
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("chrome over a pane, with no dialog open", () => {
|
||||
/** Everything that is painted over a pane and is not a blocker. */
|
||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
||||
// host — so "does the pane contain what is painted here?" made the
|
||||
// terminal's top-right corner a dead zone no user action could clear.
|
||||
["the Following/Paused toggle", () => document.createElement("button")],
|
||||
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||
// pane, and its error cards stay until dismissed.
|
||||
["a toast card", () => document.createElement("div")],
|
||||
// The drop hint is `pointer-events-none`, so a real `elementFromPoint`
|
||||
// skips it — but nothing may depend on that any more.
|
||||
["the pane's own drop hint", () => document.createElement("div")],
|
||||
// `Tooltip` portals to `document.body`, so it is nobody's child.
|
||||
["a portaled tooltip", () => document.createElement("div")],
|
||||
];
|
||||
|
||||
for (const [name, make] of CHROME) {
|
||||
it(`accepts a drop released onto ${name}`, () => {
|
||||
const el = pane({});
|
||||
const chrome = make();
|
||||
document.body.appendChild(chrome); // a sibling, not a child of the pane
|
||||
stubElementFromPoint(chrome);
|
||||
|
||||
expect(classifyDrop(el, { x: 90, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts a drop on the gutter around the terminal, and on its content", () => {
|
||||
const el = pane({});
|
||||
const child = document.createElement("span");
|
||||
el.appendChild(child);
|
||||
stubElementFromPoint(child);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
|
||||
stubElementFromPoint(el); // the gutter: the wrapper itself is topmost
|
||||
expect(classifyDrop(el, { x: 1, y: 99 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
});
|
||||
|
||||
it("accepts a drop the view could not resolve to any element", () => {
|
||||
// `elementFromPoint` answers `null` outside the viewport and `<body>`
|
||||
// over nothing in particular. With no dialog open neither is a reason
|
||||
// to refuse a drop that is inside the pane's rect.
|
||||
const el = pane({});
|
||||
stubElementFromPoint(null);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
stubElementFromPoint(document.body);
|
||||
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Routing: exactly one listener speaks for a given drop
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("routing", () => {
|
||||
it("lets only the pane the drop landed on report a refusal", () => {
|
||||
// Geometry is asked before the gate for this reason: both listeners are
|
||||
// live for every drop, and if the gate came first they would both push
|
||||
// "File drop ignored" for one drop.
|
||||
const hit = pane({});
|
||||
const missed = pane({ left: 200, right: 300, top: 200, bottom: 300 });
|
||||
openModal();
|
||||
|
||||
expect(classifyDrop(hit, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
|
||||
expect(classifyDrop(missed, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(
|
||||
"elsewhere",
|
||||
);
|
||||
});
|
||||
|
||||
it("says elsewhere, not blocked, for a hidden pane's zero-size rect", () => {
|
||||
const hidden = pane({ right: 0, bottom: 0, width: 0, height: 0 });
|
||||
openModal();
|
||||
expect(classifyDrop(hidden, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(
|
||||
"elsewhere",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HiDPI
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("physical vs logical payload coordinates", () => {
|
||||
it("divides by devicePixelRatio on Windows", () => {
|
||||
// wry's WebView2 drag-drop handler passes the OS point through in device
|
||||
// pixels, so a physical (150,150) at dpr 2 is a CSS (75,75).
|
||||
withUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
withDevicePixelRatio(2);
|
||||
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not divide on macOS or Linux, where the payload is already logical", () => {
|
||||
// The macOS and GTK backends deliver logical points and
|
||||
// `tauri-runtime-wry` does not rescale them. Halving one there aimed the
|
||||
// hit test at a point the user never touched — harmless while the test
|
||||
// was a bare rect, and a refused drop once z-order joined in.
|
||||
withDevicePixelRatio(2);
|
||||
|
||||
withUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15");
|
||||
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(false);
|
||||
expect(isDropTarget(pane({}), { x: 50, y: 50 })).toBe(true);
|
||||
|
||||
withUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36");
|
||||
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(false);
|
||||
expect(isDropTarget(pane({}), { x: 50, y: 50 })).toBe(true);
|
||||
});
|
||||
|
||||
it("takes an explicit override over the platform guess", () => {
|
||||
withUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15");
|
||||
withDevicePixelRatio(2);
|
||||
expect(
|
||||
isDropTarget(pane({}), { x: 150, y: 150 }, { physicalPixelPayload: true }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Routing for Tauri's *native* drag-drop event.
|
||||
*
|
||||
* The listener is window-wide — every pane that wants dropped file paths gets
|
||||
* the same event — so the module answers two separate questions, and keeping
|
||||
* them separate is the whole design:
|
||||
*
|
||||
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
|
||||
* payload position inside my rect? A hidden pane is `display:none` and so
|
||||
* has a zero-size rect, which is what stops two panes both claiming the
|
||||
* same drop. `TerminalView` is the only pane that takes dropped files
|
||||
* today — the Files pane copies files through buttons and a backend-opened
|
||||
* dialog, not through a drop — but the routing is what keeps it honest when
|
||||
* a second one appears.
|
||||
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
||||
* document-wide, no geometry, no z-order. While a modal or a blocking
|
||||
* overlay is on screen anywhere, every drop is refused.
|
||||
*
|
||||
* ## Why there is no z-order test here, and must not be one
|
||||
*
|
||||
* A drop that lands underneath a dialog and silently uploads into the
|
||||
* container behind it is the failure mode that matters: it is
|
||||
* invisible, it writes to the container, and the user did not ask for it.
|
||||
* Every attempt to be *precise* about which points a dialog covers has gone
|
||||
* wrong, twice, in opposite directions:
|
||||
*
|
||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||
* Permanent dead zones no user action could clear.
|
||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||
* topmost painted element, and plenty of things paint above a `z-50` modal
|
||||
* backdrop in the same stacking context: `ToastHost` is `z-[60]`, so is
|
||||
* `TerminalContextMenu`. A refused drop pushed a toast; the toast then sat
|
||||
* over the dialog; the next drop released on that toast was reported as
|
||||
* "clear" and landed in the covered directory. The gate armed its own hole.
|
||||
*
|
||||
* Both bugs are the same mistake: trusting a per-point answer to decide
|
||||
* whether the app should be accepting work at all. The document-wide question
|
||||
* has no z-index in it, so no future overlay can become a drop hole by being
|
||||
* painted high enough, and no chrome can become a dead zone by being painted
|
||||
* at all.
|
||||
*
|
||||
* What it costs: while any dialog is open, drops are refused *everywhere*,
|
||||
* including on parts of a pane the dialog does not cover. That is a state the
|
||||
* user put the app in deliberately and can leave in one keystroke, the
|
||||
* refusal is announced, and nothing is written. It is strictly the better
|
||||
* failure.
|
||||
*
|
||||
* ## jsdom
|
||||
*
|
||||
* Note for future changes: jsdom implements no layout and has no
|
||||
* `elementFromPoint`, which is how the first of those two bugs shipped green
|
||||
* through 81 drop tests — the branch was never entered in any of them. This
|
||||
* module no longer calls it (`dropTarget.test.ts` asserts that it does not),
|
||||
* so the gap can no longer hide a bug here. Anything that reintroduces a
|
||||
* geometric z-order test reintroduces the gap as well.
|
||||
*/
|
||||
|
||||
export interface DropPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything that swallows drops while it is on screen.
|
||||
*
|
||||
* `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is
|
||||
* the only way one is built, and it sets that attribute. `data-blocks-drop`
|
||||
* is for full-window overlays that are not dialogs (the shutdown overlay), and
|
||||
* `ui/Modal` puts it on its backdrop as well.
|
||||
*/
|
||||
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
|
||||
|
||||
/**
|
||||
* A blocker inside this is in the DOM but not on screen — `ui/Modal` marks
|
||||
* itself `hidden` when the pane that owns it is not the visible one, so a
|
||||
* dialog left open in project A stops refusing drops in project B the moment
|
||||
* the tab changes.
|
||||
*
|
||||
* `[hidden]` only, deliberately. `aria-hidden="true"` used to count too, and
|
||||
* it is not a visibility statement: it is routinely put on *visible*
|
||||
* decorative content (`ui/Modal`'s own ✕ glyph, every `StatusIndicator`
|
||||
* dot). An overlay that happened to sit inside such a wrapper would have
|
||||
* silently stopped blocking — the exact class of hole this gate exists to
|
||||
* close. `ui/Modal` sets `hidden`, the `hidden` attribute, and inline
|
||||
* `display:none` together, so nothing in the app depended on the aria half.
|
||||
*/
|
||||
const OFFSCREEN_SELECTOR = "[hidden]";
|
||||
|
||||
/** A blocker that is actually painted, rather than merely mounted. */
|
||||
function isOnScreen(el: Element): boolean {
|
||||
return el.closest(OFFSCREEN_SELECTOR) === null;
|
||||
}
|
||||
|
||||
/** True while a modal or a blocking overlay is on screen *anywhere*. */
|
||||
export function dropIsBlocked(doc: Document = document): boolean {
|
||||
return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen);
|
||||
}
|
||||
|
||||
/**
|
||||
* What both listeners say when they refuse a drop.
|
||||
*
|
||||
* `kind: "info"`, so it times out on its own: a drop refused because the user
|
||||
* has a dialog open is expected behaviour, not an error, and an error card
|
||||
* would sit on screen until dismissed. `dedupeKey` means three refused drops
|
||||
* leave one notice rather than a stack of three.
|
||||
*/
|
||||
export const DROP_BLOCKED_TOAST = {
|
||||
kind: "info",
|
||||
message: "File drop ignored",
|
||||
detail:
|
||||
"A dialog or full-window overlay is open, so nothing accepts dropped files. Close it and drop again.",
|
||||
dedupeKey: "drop-blocked",
|
||||
} as const;
|
||||
|
||||
export interface DropTargetOptions {
|
||||
doc?: Document;
|
||||
/** Override the ratio used to convert physical pixels to CSS pixels. */
|
||||
devicePixelRatio?: number;
|
||||
/**
|
||||
* Override the platform question "does this payload arrive in physical
|
||||
* pixels?". Tests use it; nothing in the app passes it.
|
||||
*/
|
||||
physicalPixelPayload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the drop payload's coordinates are physical device pixels.
|
||||
*
|
||||
* **Only Windows delivers physical pixels.** `wry`'s WebView2 drag-drop
|
||||
* handler reads the point from the OS in device pixels and passes it through
|
||||
* (`wry/src/webview2/drag_drop.rs`), while the macOS and GTK backends deliver
|
||||
* logical points and `tauri-runtime-wry`'s forwarding does not rescale them.
|
||||
* Dividing by `devicePixelRatio` unconditionally therefore halved every drop
|
||||
* position on a HiDPI Mac or Linux box, aiming the hit test at a point the
|
||||
* user never touched.
|
||||
*
|
||||
* Verified by reading the wry/tauri sources named above. **Not** verified on a
|
||||
* real HiDPI macOS or GTK machine — neither is available here — which is why
|
||||
* the divisor is platform-conditional rather than simply deleted: the Windows
|
||||
* behaviour is the one covered by tests and by the shipped code path.
|
||||
*/
|
||||
function payloadIsPhysical(
|
||||
view: (Window & typeof globalThis) | null,
|
||||
options: DropTargetOptions,
|
||||
): boolean {
|
||||
if (options.physicalPixelPayload !== undefined) return options.physicalPixelPayload;
|
||||
return /windows/i.test(view?.navigator?.userAgent ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a native drop at `pos` did or did not belong to `el`.
|
||||
*
|
||||
* - `accept` — it is ours, and the app is in a state to take it.
|
||||
* - `blocked` — it was aimed at us, but a modal or a blocking overlay is on
|
||||
* screen. Worth *saying* to the user: the drop visibly did nothing.
|
||||
* - `elsewhere` — not our drop. Silence is the right response; some other
|
||||
* pane's listener may be about to accept it.
|
||||
*
|
||||
* Geometry is asked **first**, so exactly one pane can ever answer `blocked`
|
||||
* for a given drop and the refusal is announced once rather than once per
|
||||
* listener.
|
||||
*/
|
||||
export type DropVerdict = "accept" | "blocked" | "elsewhere";
|
||||
|
||||
export function classifyDrop(
|
||||
el: HTMLElement | null | undefined,
|
||||
pos: DropPoint,
|
||||
options: DropTargetOptions = {},
|
||||
): DropVerdict {
|
||||
const doc = options.doc ?? el?.ownerDocument ?? document;
|
||||
|
||||
const rect = el?.getBoundingClientRect();
|
||||
if (!el || !rect || rect.width === 0 || rect.height === 0) return "elsewhere";
|
||||
|
||||
const view = doc.defaultView;
|
||||
const dpr =
|
||||
options.devicePixelRatio ??
|
||||
(payloadIsPhysical(view, options) ? view?.devicePixelRatio || 1 : 1);
|
||||
const x = pos.x / dpr;
|
||||
const y = pos.y / dpr;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
return "elsewhere";
|
||||
}
|
||||
|
||||
// Whose drop it is has been settled. Whether the app should be taking drops
|
||||
// at all is a separate, document-wide question — see the header.
|
||||
return dropIsBlocked(doc) ? "blocked" : "accept";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a native drop at `pos` (physical pixels on Windows, logical
|
||||
* elsewhere) belongs to `el`.
|
||||
*/
|
||||
export function isDropTarget(
|
||||
el: HTMLElement | null | undefined,
|
||||
pos: DropPoint,
|
||||
options: DropTargetOptions = {},
|
||||
): boolean {
|
||||
return classifyDrop(el, pos, options) === "accept";
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("defaults to base 1000, because that is what Docker prints", () => {
|
||||
// Anything explaining `docker system df` has to match it, and Docker
|
||||
// formats with `units.HumanSize` — base 1000. Showing 26.1 GB against a
|
||||
// terminal saying 28.0 GB for the same object reads as a bug in the app.
|
||||
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
|
||||
expect(formatBytes(1_000)).toBe("1.0 KB");
|
||||
expect(formatBytes(1_500_000)).toBe("1.5 MB");
|
||||
expect(formatBytes(12_273_392_374)).toBe("12.3 GB");
|
||||
});
|
||||
|
||||
it("leaves whole bytes without a decimal point", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(999)).toBe("999 B");
|
||||
});
|
||||
|
||||
it("reproduces the Project Home convention exactly under `binary`", () => {
|
||||
// Three modules import `projects/home/format.ts#formatBytes`, which is now
|
||||
// this function. Its output had to be byte-identical or re-pointing it
|
||||
// would have quietly changed every file listing in the app.
|
||||
expect(formatBytes(1023, { binary: true })).toBe("1023 B");
|
||||
expect(formatBytes(1024, { binary: true })).toBe("1.0 KB");
|
||||
expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB");
|
||||
expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB");
|
||||
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
|
||||
});
|
||||
|
||||
it("absorbs the last two ad-hoc formatters, behaviour change and all", () => {
|
||||
// `UpdateDialog.formatSize` and the inline `toFixed(1)` in
|
||||
// `useProjectActions` both divided by 1024 and both stopped at MB. Routing
|
||||
// them here is what finally makes this the *only* byte formatter, and it
|
||||
// changes two things on purpose — pinned so neither reads as a regression
|
||||
// to whoever meets them next.
|
||||
//
|
||||
// KB gains a decimal, matching every other size in the app:
|
||||
expect(formatBytes(512 * 1024, { binary: true })).toBe("512.0 KB");
|
||||
// and the ladder no longer bottoms out at a five-digit megabyte count:
|
||||
expect(formatBytes(2 * 1024 ** 3, { binary: true })).toBe("2.0 GB");
|
||||
// Sub-kilobyte sizes stop rendering as "0 KB", which is what the old
|
||||
// `(bytes / 1024).toFixed(0)` said about every release asset under 512 B.
|
||||
expect(formatBytes(400, { binary: true })).toBe("400 B");
|
||||
});
|
||||
|
||||
it("reproduces the migration convention exactly by default", () => {
|
||||
// `migrationCopy.formatDataSize` is now a call to this, and its output is
|
||||
// asserted in MigrateContainerModal.test.tsx.
|
||||
expect(formatBytes(41_000_000)).toBe("41.0 MB");
|
||||
expect(formatBytes(3_800_000_000)).toBe("3.8 GB");
|
||||
});
|
||||
|
||||
it("labels binary units honestly when asked to", () => {
|
||||
expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB");
|
||||
});
|
||||
|
||||
it("climbs to TB rather than showing five-digit gigabytes", () => {
|
||||
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
|
||||
});
|
||||
|
||||
it("promotes the unit when rounding lands on a whole step", () => {
|
||||
// `toFixed` runs after the divide loop, so a value just under a boundary
|
||||
// rounds up into a unit the loop had already ruled out. This is the app's
|
||||
// only byte formatter and the panel is full of near-boundary sizes.
|
||||
expect(formatBytes(999_999)).toBe("1.0 MB");
|
||||
expect(formatBytes(999_999_999)).toBe("1.0 GB");
|
||||
expect(formatBytes(999_999_999_999)).toBe("1.0 TB");
|
||||
expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB");
|
||||
|
||||
// Just below the rounding threshold it must NOT promote.
|
||||
expect(formatBytes(999_949)).toBe("999.9 KB");
|
||||
expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB");
|
||||
|
||||
// The top unit has nowhere to go: it renders a whole step rather than
|
||||
// running off the end of the unit array.
|
||||
expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB");
|
||||
});
|
||||
|
||||
it("renders an em dash for a size the daemon did not compute", () => {
|
||||
// Docker reports -1 for "not calculated" on shared sizes and volume ref
|
||||
// counts. `NaN GB` in the middle of a table is worse than nothing.
|
||||
expect(formatBytes(-1)).toBe("—");
|
||||
expect(formatBytes(NaN)).toBe("—");
|
||||
expect(formatBytes(Infinity)).toBe("—");
|
||||
});
|
||||
|
||||
it("honours a requested precision", () => {
|
||||
expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB");
|
||||
expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesDelta", () => {
|
||||
it("signs a figure that is being added rather than measured", () => {
|
||||
// "Next commit adds +868.0 MB" — the sign is what makes it read as a cost
|
||||
// about to be incurred rather than a size already on disk.
|
||||
expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB");
|
||||
expect(formatBytesDelta(0)).toBe("+0 B");
|
||||
});
|
||||
|
||||
it("does not sign an unknown", () => {
|
||||
expect(formatBytesDelta(-1)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesCeiling", () => {
|
||||
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
|
||||
// A projected yield cannot be known until the work runs, unlike every
|
||||
// measured figure beside it — rendering it through a separate function is
|
||||
// what stops it being read as a guarantee.
|
||||
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
|
||||
});
|
||||
|
||||
it("refuses to imply a saving when there is no bound to give", () => {
|
||||
expect(formatBytesCeiling(0)).toBe("an unknown amount");
|
||||
expect(formatBytesCeiling(-1)).toBe("an unknown amount");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The one byte formatter.
|
||||
*
|
||||
* The app had four of them — `projects/home/format.ts`,
|
||||
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
||||
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
||||
* unit labels and the precision. All four now delegate here, and there are no
|
||||
* remaining copies.
|
||||
*
|
||||
* The last two were held back because re-pointing them changes what they
|
||||
* render, and that turned out to be the argument for doing it rather than
|
||||
* against. `UpdateDialog` rendered KB at `toFixed(0)` (`512 KB` is now
|
||||
* `512.0 KB`, consistent with every other size in the app) and both stopped
|
||||
* the ladder at MB, so a 2 GB asset or backup read as a five-digit number of
|
||||
* megabytes. Both are `{ binary: true }`: they describe files, and a host file
|
||||
* browser shows the ÷1024 figure for the same bytes.
|
||||
*
|
||||
* ## Why the default is base 1000
|
||||
*
|
||||
* Anything explaining what Docker reports has to match it, and Docker formats
|
||||
* every size it prints with `units.HumanSize`, which is **base 1000**. Showing
|
||||
* 26.1 GB where the user's terminal said 28.0 GB for the same object would read
|
||||
* as a bug in the app. So decimal is the default and binary is opt-in, rather
|
||||
* than the other way round.
|
||||
*
|
||||
* Both existing conventions are preserved for every size either call site can
|
||||
* realistically produce — a file size or a payload size, i.e. a non-negative
|
||||
* finite number below a terabyte. Outside that range this deliberately differs
|
||||
* from what it replaced: a negative or `NaN` input now renders `—` rather than
|
||||
* `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of
|
||||
* stopping there.
|
||||
*
|
||||
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||
* labels, what Project Home used
|
||||
* — technically a misnomer, but
|
||||
* it is the app's convention and
|
||||
* changing it is not this
|
||||
* feature's business)
|
||||
* - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly)
|
||||
*/
|
||||
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/** Divide by 1024 instead of 1000. */
|
||||
binary?: boolean;
|
||||
/** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */
|
||||
iec?: boolean;
|
||||
/** Decimal places above `B`. Bytes are always whole. */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {
|
||||
const { binary = false, iec = false, precision = 1 } = options;
|
||||
|
||||
// A negative or non-finite size is a bug upstream, not something to render as
|
||||
// `NaN GB` in the middle of a table. Docker reports -1 for "not computed",
|
||||
// and that is the case this actually catches.
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
||||
|
||||
const step = binary ? 1024 : 1000;
|
||||
const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS;
|
||||
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= step && unit < units.length - 1) {
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
// **Promote again if rounding pushed the value back up to a whole step.**
|
||||
// `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then
|
||||
// renders as "1000.0 KB" — a unit the loop had already decided against. The
|
||||
// same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575
|
||||
// → "1024.0 KB" in binary).
|
||||
if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) {
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
||||
return unit === 0
|
||||
? `${Math.round(bytes)} ${units[0]}`
|
||||
: `${value.toFixed(precision)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
|
||||
* measured. Used for "next commit adds …", which is the number that explains
|
||||
* why a snapshot grows.
|
||||
*
|
||||
* **No caller on this branch**, for the same reason as [`formatBytesCeiling`]:
|
||||
* the Disk panel's per-project table was the last one, and it went to
|
||||
* `hold/disk-and-dragout`.
|
||||
*/
|
||||
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
|
||||
const formatted = formatBytes(bytes, options);
|
||||
return formatted === "—" ? formatted : `+${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `up to 12.3 GB` — for a bound rather than a measurement.
|
||||
*
|
||||
* A figure that cannot be known until an operation runs must not render like
|
||||
* one that was measured; going through a different function is what stops it
|
||||
* being read as a promise.
|
||||
*
|
||||
* **No caller on this branch.** Its last one was the Disk panel's projected
|
||||
* compaction yield, which went to `hold/disk-and-dragout`. Kept with its tests
|
||||
* because the distinction it encodes is the reusable part.
|
||||
*/
|
||||
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
|
||||
return `up to ${formatBytes(bytes, options)}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user