Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e3ec1150 | ||
|
|
85901d8a80 | ||
|
|
8305c96e20 | ||
|
|
3537b234d8 | ||
|
|
83c9c24951 | ||
|
|
c6f9c1d43f | ||
|
|
593b8168eb | ||
|
|
d647b56b43 | ||
|
|
a3840f7263 | ||
|
|
ac50c38891 | ||
|
|
f662ed04ce | ||
|
|
f311ca1990 | ||
|
|
84a5757c74 | ||
|
|
73a6e3d8b4 | ||
|
|
943c83b9e3 | ||
|
|
60188610ee | ||
|
|
db648230ee | ||
|
|
5a452e7a2a | ||
|
|
5a09254538 | ||
|
|
9297020688 | ||
|
|
bf8094dbc4 | ||
|
|
90b7e4ccb2 | ||
|
|
afe9d5cdb2 | ||
|
|
b59c6148ff | ||
|
|
95a78fe9a3 | ||
|
|
307ea07409 | ||
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec | ||
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 | ||
|
|
670450ccfd | ||
|
|
a0b9f1e19b | ||
|
|
9fadfbc37a | ||
|
|
a3bdf6f4da | ||
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 | ||
|
|
3239057f8f | ||
|
|
23364f412e | ||
|
|
b24807bd5f | ||
|
|
0f3fff92f4 | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
2708772bf9 | ||
|
|
436b6dd470 | ||
|
|
5c47656444 | ||
|
|
be47c5edfd | ||
|
|
037ed78570 | ||
|
|
31e8f9df5f | ||
|
|
3704064006 | ||
|
|
f79a44e0a8 | ||
|
|
5a8e24ccbe | ||
|
|
a1f4eee9a3 | ||
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 | ||
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 | ||
|
|
1a79852f65 | ||
|
|
68b73a9102 | ||
|
|
d09e2a2743 | ||
|
|
4371c9f03e | ||
|
|
eead748222 | ||
|
|
2c9482a67d | ||
|
|
88ffb4744a | ||
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 | ||
|
|
168b61d632 | ||
|
|
47960e46df | ||
|
|
73dfaf5785 | ||
|
|
01fd38bc4b | ||
|
|
00128f9b1a | ||
|
|
39934299f9 | ||
|
|
c6086b0ab3 | ||
|
|
f7db4323be | ||
|
|
ed91423666 | ||
|
|
6a8972980d | ||
|
|
7bbb699e4e | ||
|
|
5df3e7996d | ||
|
|
5d4d5d37df | ||
|
|
bb1c7696f9 | ||
|
|
f2a84c18f9 | ||
|
|
b49dddab45 | ||
|
|
dcd2dfe5a3 | ||
|
|
6d27f924ff | ||
|
|
e70a40507c | ||
|
|
5926a52ff6 | ||
|
|
42ef1865cc | ||
|
|
4f6c012071 | ||
|
|
6b8d43414d | ||
|
|
1768240861 | ||
|
|
7e1f8df1ff | ||
|
|
a76f2c0a17 | ||
|
|
17f031a5d7 | ||
|
|
5fba7d6d35 | ||
|
|
433afa5a49 | ||
|
|
fcea506dce | ||
|
|
6abc7f27a4 | ||
|
|
2b6501d8e5 | ||
|
|
3329e07d3d | ||
|
|
092972fe92 | ||
|
|
ae3ca8cda4 | ||
|
|
d6f065a2b6 | ||
|
|
0003793abb | ||
|
|
2b9bf56f25 | ||
|
|
611f67cca7 | ||
|
|
77ef2291d7 | ||
|
|
1c834a0b08 | ||
|
|
0a022dfcf0 | ||
|
|
bb41275cea | ||
|
|
2ca86bb5d8 | ||
|
|
df6d2f1ca4 | ||
|
|
dacc1157ec | ||
|
|
dd2894cc60 | ||
|
|
22d142c70d | ||
|
|
15e05e2197 | ||
|
|
75cace7dde | ||
|
|
24590546e3 | ||
|
|
d971326e4e | ||
|
|
48d0c3249a | ||
|
|
5b96ad4823 | ||
|
|
dcb13d23ea | ||
|
|
7a8bbcbef7 | ||
|
|
3bd3caa101 | ||
|
|
5dd1ab5217 | ||
|
|
92d64cf252 | ||
|
|
ab2c75d0b2 | ||
|
|
00937745f7 | ||
|
|
01e72e4785 | ||
|
|
2b35aa8c16 | ||
|
|
65a3d4eb29 | ||
|
|
2b2d9da606 | ||
|
|
3741e0fef5 | ||
|
|
0e6566d903 | ||
|
|
84a67fcd0d | ||
|
|
f3cc1c4c17 | ||
|
|
7265f55f27 | ||
|
|
88f2e73474 | ||
|
|
fa4940dd7d | ||
|
|
9027fa9ad4 | ||
|
|
be37723c38 | ||
|
|
5f990dd28b | ||
|
|
4df59da2d8 | ||
|
|
a72406f0d8 | ||
|
|
9b2f4fe79f | ||
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b |
@@ -5,9 +5,13 @@ name: Build App (Preview)
|
||||
# sync.
|
||||
#
|
||||
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||
# unreachable set of bundles; it is now releases-only.
|
||||
# a push that breaks any of them fails here. Its `test` job runs vitest and
|
||||
# `cargo test` too, so a push that breaks either suite fails here as well.
|
||||
# Previews are not code-signed (releases are, in build-app.yml): see the
|
||||
# comment on the Windows job's "Build Tauri app" step.
|
||||
# build-app.yml used to do the build-check job in parallel and publish nothing,
|
||||
# which meant six OS builds per push and one unreachable set of bundles; it is
|
||||
# now releases-only.
|
||||
#
|
||||
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
||||
# touches `app/**` — so the workflow prunes its own, keeping the newest
|
||||
@@ -43,7 +47,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 +85,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 +112,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
|
||||
@@ -160,6 +225,102 @@ jobs:
|
||||
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||
|
||||
# The test suites. Before this job CI ran neither: every check below lived on
|
||||
# a developer's machine. The one that matters most is the app-command ACL
|
||||
# census — `cargo test` is what re-checks the committed capability files and
|
||||
# `gen/schemas/acl-manifests.json` against `generate_handler!`, and vitest's
|
||||
# `capabilities.test.ts` is what keeps each window's code to the wrappers its
|
||||
# capability grants. A command left ungranted builds fine and only fails at
|
||||
# runtime ("not allowed by ACL"), so these tests are the merge-time guard.
|
||||
#
|
||||
# Independent of the release: no `needs`, so it runs alongside the three
|
||||
# platform builds rather than in front of them, and a red test fails the PR
|
||||
# check without holding up a preview someone may want to try anyway.
|
||||
#
|
||||
# Setup mirrors build-linux on purpose — the same Node, the same apt set
|
||||
# (`cargo test` compiles the whole Tauri crate, so it needs WebKitGTK like
|
||||
# a real build) and `npm ci` from the lockfile for the reasons given there.
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
NEED_INSTALL=false
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||
OLD_NODE_DIR=$(dirname "$(which node)")
|
||||
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
||||
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
||||
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
||||
hash -r
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
else
|
||||
echo "Node.js not found, installing 22..."
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
curl
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup update stable
|
||||
rustup default stable
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
fi
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: npm ci
|
||||
|
||||
# `npm run build` is `tsc && vite build`: the type check, and the
|
||||
# `dist/` that `tauri::generate_context!` needs to exist before the Rust
|
||||
# crate — and so `cargo test` — will compile at all.
|
||||
- name: Type-check and build the frontend
|
||||
working-directory: ./app
|
||||
run: npm run build
|
||||
|
||||
- name: Frontend tests (vitest)
|
||||
working-directory: ./app
|
||||
run: npx vitest run
|
||||
|
||||
# `--locked`: test against the committed Cargo.lock, never a re-resolved
|
||||
# one, for the same reason the frontend uses `npm ci`.
|
||||
- name: Backend tests (cargo test)
|
||||
working-directory: ./app/src-tauri
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --locked
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, create-release]
|
||||
@@ -238,8 +399,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -249,16 +436,31 @@ 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
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
@@ -350,8 +552,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -361,6 +565,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
|
||||
@@ -464,7 +671,10 @@ jobs:
|
||||
- name: Install Tauri CLI via cargo
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo install tauri-cli --version "^2"
|
||||
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
|
||||
rem the Linux and macOS jobs run, and kept identical to build-app.yml so
|
||||
rem a preview is built by the same bundler as the release it previews.
|
||||
cargo install tauri-cli --version "=2.11.0" --locked
|
||||
|
||||
- name: Fix npm platform detection
|
||||
run: |
|
||||
@@ -487,11 +697,21 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
# Previews are not code-signed: signing is metered, previews are built
|
||||
# on every PR push, and a PR's workflow runs the PR's own code - so the
|
||||
# signing secrets stay out of this workflow entirely. Releases are
|
||||
# signed in build-app.yml.
|
||||
#
|
||||
# beforeBuildCommand is blanked through --config because the frontend
|
||||
# was built in the step above. Not TAURI_CONFIG: the v2 CLI never
|
||||
# reads that variable, and the inline one this step used to set was a
|
||||
# no-op.
|
||||
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
|
||||
cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
- "scripts/windows-*.ps1"
|
||||
workflow_dispatch:
|
||||
|
||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||
@@ -39,13 +40,48 @@ jobs:
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||
|
||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
# The patch number is **one past the highest patch already used**, and
|
||||
# never a distance.
|
||||
#
|
||||
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
||||
# not a counter at all: it measures how far HEAD has drifted from
|
||||
# whichever tag sorts highest, and that resets to zero every time a
|
||||
# tag is cut. The published history is the proof — each of these is
|
||||
# exactly what the old formula returned at the time:
|
||||
#
|
||||
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
||||
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
||||
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
||||
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
||||
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
||||
#
|
||||
# Reusing a version is worse than failing to publish one: the macOS
|
||||
# and Windows steps replace assets in place, so a duplicate silently
|
||||
# rewrote a release that had been public for three days. Monotonic
|
||||
# numbering is what stops that at the source.
|
||||
#
|
||||
# Suffixed tags count too. `create-tag` is skipped when any platform
|
||||
# job fails, so a run can publish v0.4.7-mac and never create the
|
||||
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
||||
# same number out twice.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
# A re-run of a commit that already released must not mint a new
|
||||
# version just because its own tag now exists.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||
@@ -137,8 +173,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -150,42 +212,126 @@ jobs:
|
||||
working-directory: ./app
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
# The versioned AppImage only. The update channel's copy lives in
|
||||
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||
# it up and publish an 80 MB duplicate under a second name.
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# A green job that published nothing is the worst outcome available:
|
||||
# the release exists, carries no AppImage, and nobody is told. The
|
||||
# `|| true` above is there so a missing bundle does not mask the real
|
||||
# error, which makes this check the thing that catches it.
|
||||
shopt -s nullglob
|
||||
collected=(artifacts/*)
|
||||
if [ ${#collected[@]} -eq 0 ]; then
|
||||
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
|
||||
# Idempotent get-or-create, matching build-macos. This step used to
|
||||
# POST /releases unconditionally: against a tag that already existed
|
||||
# Gitea answered 409, the grep below found no id, and the run died
|
||||
# with a bare "exitcode '1'" and not one line of output explaining
|
||||
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
||||
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
||||
# pipefail so a failure cannot be stepped over.
|
||||
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||
case "${HTTP_CODE}" in
|
||||
200)
|
||||
echo "Release ${TAG} already exists, reusing"
|
||||
;;
|
||||
404)
|
||||
echo "Creating release ${TAG}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
# Upload each artifact
|
||||
|
||||
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
||||
# Versions are monotonic now (see compute-version), so this can only
|
||||
# ever be replacing an asset from a failed run of this same commit —
|
||||
# never one belonging to an already-published version.
|
||||
for file in artifacts/*; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
echo "Uploading ${filename}..."
|
||||
curl -s -X POST \
|
||||
curl -fsS --http1.1 \
|
||||
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||
--max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
# The fixed tag every installed AppImage checks for updates. Separate
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
bash scripts/publish-update-channel.sh \
|
||||
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
@@ -241,8 +387,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -464,7 +612,11 @@ jobs:
|
||||
- name: Install Tauri CLI via cargo
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo install tauri-cli --version "^2"
|
||||
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
|
||||
rem the Linux and macOS jobs run: the Windows code-signing path (sign
|
||||
rem command, NSIS uninstaller signing) was verified against it, and "^2"
|
||||
rem would change it underneath the pipeline on any Tauri release.
|
||||
cargo install tauri-cli --version "=2.11.0" --locked
|
||||
|
||||
- name: Fix npm platform detection
|
||||
run: |
|
||||
@@ -485,10 +637,31 @@ jobs:
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
npm run build
|
||||
|
||||
# Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1).
|
||||
# The setup fetches the signing client and a job-local .NET runtime, and
|
||||
# writes the Tauri config holding the sign command, which "Build Tauri
|
||||
# app" passes with --config. A missing secret fails here, before the build.
|
||||
- name: Prepare code signing
|
||||
env:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
|
||||
ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
|
||||
ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }}
|
||||
run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
# The sign command comes in through --config, from the file "Prepare
|
||||
# code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that
|
||||
# variable (the inline one this step used to set was a no-op), and
|
||||
# "Verify signatures" is what caught it.
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# Read by the signing dlib itself, never passed on a command line.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
||||
@@ -503,7 +676,20 @@ jobs:
|
||||
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
|
||||
rem System32 originals, which makes the redirected view resolve. A
|
||||
rem runner running as a normal user needs no such patch.
|
||||
cargo tauri build --bundles msi,nsis
|
||||
cargo tauri build --bundles msi,nsis --config "%TRIPLE_C_TAURI_SIGN_CONFIG%"
|
||||
|
||||
- name: Verify signatures
|
||||
run: >-
|
||||
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
|
||||
-File scripts\windows-verify-signatures.ps1
|
||||
app\src-tauri\target\release\bundle\msi\*.msi
|
||||
app\src-tauri\target\release\bundle\nsis\*.exe
|
||||
|
||||
# Tauri reports a failed sign command as just "failed to run powershell";
|
||||
# windows-sign.ps1 keeps its own transcript, signtool /debug included.
|
||||
- name: Show signing output
|
||||
if: failure()
|
||||
run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
|
||||
@@ -28,6 +28,27 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# Put BuildKit in the host's network namespace so it can reach
|
||||
# act_runner's cache service.
|
||||
#
|
||||
# The `docker-container` driver — which the multi-arch build below
|
||||
# requires, since the plain `docker` driver cannot do
|
||||
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
|
||||
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
|
||||
# an address the *job* container can reach, and nothing teaches the
|
||||
# BuildKit container about it: the job could reach
|
||||
# 192.168.1.126:40649 while the container actually making the request
|
||||
# could not, and the build died with `no route to host`.
|
||||
#
|
||||
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
|
||||
# missing route (a wrong address times out instead) — which is what a
|
||||
# default firewalld zone does to traffic arriving from the docker
|
||||
# bridge. Sharing the host's namespace sidesteps the question
|
||||
# entirely: the cache address becomes local to BuildKit.
|
||||
#
|
||||
# No effect on runners where this already worked.
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -55,5 +76,21 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
# `ignore-error` is what stops a cache failure failing a build that
|
||||
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||
# service on the runner host's LAN address, and the `docker-container`
|
||||
# builder `setup-buildx-action` creates could not route to it —
|
||||
# every layer of both arches built, then the job died on
|
||||
# `GetCacheEntryDownloadURL: no route to host` while exporting.
|
||||
#
|
||||
# On a pull_request `push:` above is false, so this job pushes
|
||||
# nothing and the cache is its only output: failing it discarded a
|
||||
# complete, successful validation of the Dockerfile for both
|
||||
# architectures. A cache is an optimisation and must degrade to
|
||||
# "slow", never to "red".
|
||||
#
|
||||
# The import is already non-fatal — the build ran all 37 layers after
|
||||
# warning that it could not read the cache — so only the exporter
|
||||
# needs the flag.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Secret Scan
|
||||
|
||||
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||
# touching the wrong directory is not a scan.
|
||||
#
|
||||
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||
# from inside a repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||
# but depends on getting the range right across pushes, force-pushes,
|
||||
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||
# takes under half a second on this repository and cannot be evaded by
|
||||
# arranging for the interesting commit to sit outside the window.
|
||||
- name: Scan tracked files for credentials
|
||||
run: sh scripts/scan-secrets.sh --tracked
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit that adds something shaped like a live credential.
|
||||
#
|
||||
# Installed by pointing git at this directory:
|
||||
#
|
||||
# git config core.hooksPath .githooks
|
||||
#
|
||||
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||
# a repo would then be enough to run its code. So this is opt-in on every
|
||||
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||
# hook exists to tell you in one second rather than in five minutes.
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||
@@ -1,5 +1,21 @@
|
||||
node_modules/
|
||||
app/dist/
|
||||
app/src-tauri/target/
|
||||
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
|
||||
# tracked, reviewable form of the same information.
|
||||
app/src-tauri/permissions/autogenerated/
|
||||
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
|
||||
|
||||
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||
.code-signing/
|
||||
|
||||
@@ -71,13 +71,29 @@ npm ci
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
npx tauri build --bundles appimage
|
||||
```
|
||||
|
||||
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
|
||||
publish for an audience the AppImage already serves, and neither could
|
||||
self-update. A bare `npx tauri build` still emits them, because
|
||||
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
|
||||
untouched; they are not released and not tested.
|
||||
|
||||
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||
|
||||
| Format | Path |
|
||||
|------------|-------------------------------|
|
||||
| AppImage | `appimage/*.AppImage` |
|
||||
| Debian pkg | `deb/*.deb` |
|
||||
| RPM pkg | `rpm/*.rpm` |
|
||||
| Format | Path | Released |
|
||||
|------------|-------------------------------|----------|
|
||||
| AppImage | `appimage/*.AppImage` | yes |
|
||||
| Debian pkg | `deb/*.deb` | no |
|
||||
| RPM pkg | `rpm/*.rpm` | no |
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
|
||||
section of `CLAUDE.md` for why both of its steps are load-bearing.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -73,13 +73,78 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
|
||||
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
|
||||
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
|
||||
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
|
||||
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
|
||||
style nonce and CodeMirror's injected styles would stop applying. A missing or broken
|
||||
`viewer.html` Vite entry is not caught by Tauri at build time — both Vite dev and Tauri's asset
|
||||
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
|
||||
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
|
||||
in `file_viewer/mod.rs` is the only thing pinning this.
|
||||
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||
and the editors reused by Project Home
|
||||
- **`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`,
|
||||
@@ -106,6 +171,19 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
|
||||
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||
`auth_token_commands.rs`.
|
||||
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
|
||||
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
|
||||
user (spec §5 says why the archive API never writes to the target directory). Commands take
|
||||
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
|
||||
viewer command accepts a path. Which window may *call* each command is the ACL's job: the
|
||||
`file-viewer-*` capability grants exactly the five `viewer_*` commands (see `build.rs`).
|
||||
- **`build.rs` + `src/command_census.rs`** — the build declares a Tauri `AppManifest` from the
|
||||
`generate_handler!` list and refuses to build unless every command has exactly one bare
|
||||
`allow-*` grant in the capability file its name says it belongs to. The parser and rules are
|
||||
in `command_census.rs`, compiled into both the build script and the test build, so they are
|
||||
unit-tested; `the_generated_app_manifest_matches_the_handler_list` reads back what tauri
|
||||
embedded. Design: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
|
||||
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
|
||||
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
|
||||
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
|
||||
@@ -203,7 +281,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
### Container (`container/`)
|
||||
|
||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||
libraries a browser links against (see below)
|
||||
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||
@@ -273,6 +352,110 @@ migration and Reset. Four things here are not obvious:
|
||||
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
||||
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
||||
|
||||
### VPN support (`vpn_support_enabled`, `docker/container.rs`)
|
||||
|
||||
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
|
||||
`vpn_host_config()` is the single definition of what that means, and it is unit-tested because a
|
||||
container is created once by a very long function where a dropped capability is invisible.
|
||||
|
||||
- **All three pieces or none.** `CAP_NET_ADMIN` (Docker's default set has `net_raw` but *not*
|
||||
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
|
||||
entirely from a default container — nothing to open even with the capability), and
|
||||
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
|
||||
container, since `/proc/sys` is read-only, so handshake packets die to reverse-path filtering).
|
||||
Any two without the third still presents as a connection that hangs to a timeout, which is why
|
||||
the tests assert the whole set.
|
||||
- **The device is passed through from the host, never `mknod`-ed inside.** The kernel's `tun`
|
||||
module has to back it.
|
||||
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
|
||||
create --device /dev/does-not-exist` succeeds and prints an id; runc resolves the device (and
|
||||
validates sysctls) only when it builds the container. So the guard belongs on the start path:
|
||||
`explain_container_failure()` covers both and is called from `start_container`, where it has a
|
||||
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
|
||||
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
|
||||
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
|
||||
- **`NET_ADMIN` here is not user-namespaced.** Docker does not enable userns remapping by default,
|
||||
so only the *network* namespace confines it: no reach onto host interfaces, but promiscuous
|
||||
mode, arbitrary addresses/routes/NAT on the shared `docker0` segment (sibling containers, the
|
||||
LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and
|
||||
enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the
|
||||
code comments honest about this — an earlier draft claimed it "confers no authority" outside the
|
||||
container, which is too strong.
|
||||
- **`triple-c.vpn-support` is written unconditionally, including `false`.** The usual
|
||||
`docker commit` reason: a `true` stamped once would ride the snapshot image into every future
|
||||
container and make the switch impossible to turn off.
|
||||
- Off is byte-identical to a container created before the feature existed, and a missing label
|
||||
reads as `false`, so no existing project is churned.
|
||||
- **The toggle grants capability and stops there — it routes nothing.** `vpn_host_config()` returns
|
||||
a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started
|
||||
or restored. Users read the name as "turn the VPN on" and report the default network not routing
|
||||
through it as a bug. It isn't, and the docs say so explicitly; keep it that way.
|
||||
- **The tooling is baked, not installed at runtime.** `iproute2` and `wireguard-tools` are in
|
||||
`container/Dockerfile` because a runtime install lands in the writable layer and is lost on
|
||||
base-image migration — leaving a project holding the capability with nothing able to exercise it,
|
||||
and no error that points at why. `iptables` is included and `nftables` deliberately is not; see
|
||||
the Dockerfile comment for why that way round.
|
||||
- **Anything built on this fails open.** The network namespace is rebuilt on every start and no
|
||||
service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover
|
||||
`/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
|
||||
writable layer, so on a stop/start it is simply the same container's files, and on a recreation
|
||||
`docker commit` has carried it into the snapshot. Traffic silently reverts to the real address.
|
||||
Any future autostart or killswitch work starts here.
|
||||
- **`/run` riding the snapshot means a VPN client's key material can end up in an image.** Verified:
|
||||
a fresh container off the whp snapshot already contained the `wg.priv` a previous tunnel left in
|
||||
`/run`. Anything writing key material there inherits the problem — the same `docker commit`
|
||||
hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks
|
||||
ephemeral and is not. A VPN client that does this should delete its key on teardown.
|
||||
- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
|
||||
nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
|
||||
any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
|
||||
the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
|
||||
`nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
|
||||
`xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment;
|
||||
the kernel-config evidence is quoted there.
|
||||
- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need
|
||||
`xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every
|
||||
provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it
|
||||
breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in
|
||||
systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route`
|
||||
directly avoids both, which is what the skill does.
|
||||
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
|
||||
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
|
||||
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
|
||||
image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
|
||||
The removal branch matters as much as the install: `~/.claude` is a persisted volume, so a skill
|
||||
left behind after the toggle goes off would keep instructing an agent to use a capability the
|
||||
container no longer has. Which is also why the variable is sent as `0` rather than omitted (see
|
||||
`vpn_env_var`, tested), and why it is in `RESERVED_ENV_EXACT` — a custom env var of that name
|
||||
could otherwise claim the skill without the capability behind it.
|
||||
- **Both halves of that live in the base image, so neither reaches an existing project.** A
|
||||
recreation builds from the project's *own snapshot*, which has no `/opt/triple-c-skills` and no
|
||||
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
|
||||
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so the
|
||||
migration pre-flight lists it as missing. Worth knowing before adding anything else behind an
|
||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||
so a project already at `true` gets no recreation at all on upgrade.
|
||||
|
||||
### Keeping Claude Code current
|
||||
|
||||
`claude update` runs in **two** places, and both are needed:
|
||||
|
||||
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||
command every Claude session launches with, because containers use a stop/start model and a
|
||||
long-lived one would otherwise never re-check.
|
||||
|
||||
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||
deliberately untouched.
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||
@@ -296,6 +479,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
||||
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
|
||||
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
|
||||
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
|
||||
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
|
||||
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
|
||||
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
|
||||
for the same reason a running container already outranked it. Assuming a snapshot existed is what
|
||||
made a stopped, never-recreated project report "no container or snapshot image yet" with its
|
||||
container sitting right there, and left Update disabled on the projects furthest behind.
|
||||
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
|
||||
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
|
||||
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
|
||||
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
|
||||
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
|
||||
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
|
||||
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
|
||||
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
|
||||
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
|
||||
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
|
||||
layer acceptable. Two rules it earned the hard way:
|
||||
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
|
||||
daemon-wide, so a second copy of the app has live probe images matching the glob.
|
||||
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
|
||||
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
|
||||
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
|
||||
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
|
||||
two concurrent probes fought over one tag — whichever finished first force-removed the image the
|
||||
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
|
||||
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
|
||||
uniqueness is what makes overlapping probes safe.
|
||||
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
|
||||
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
|
||||
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
|
||||
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
|
||||
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
|
||||
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
|
||||
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
|
||||
failed to invalidate would plan a migration against a filesystem the project no longer has.
|
||||
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
|
||||
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
|
||||
which leaves the migrate action in the project menu enabled — that action is not gated on the
|
||||
banner — so the pre-flight would report nothing to copy while the backend was told to copy
|
||||
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
|
||||
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
|
||||
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
|
||||
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
|
||||
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
|
||||
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
|
||||
- **`get_container_staleness` never commits while the project is claimed.** It takes no
|
||||
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
|
||||
reads `project_lock::held` instead and probes the snapshot rather than the container. The
|
||||
collision is not symmetric: the probe losing is a retryable `probe_error`, but
|
||||
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
|
||||
commit would fail the user's Start with an opaque error.
|
||||
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
|
||||
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
|
||||
stamps *now* — which is what real probe images do, and what any fixture for them must do.
|
||||
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||
@@ -361,9 +601,28 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
|
||||
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
||||
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
||||
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
|
||||
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
|
||||
need an entry there — adding one is not required and none exists for any app command.
|
||||
- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
|
||||
`lib.rs`, and a bare `allow-<name-with-dashes>` entry in the one capability file for the
|
||||
window that calls it — `viewer_*` commands in `capabilities/file-viewer.json`, everything else
|
||||
in `capabilities/default.json`. `build.rs` declares a Tauri `AppManifest` from the handler list
|
||||
(without one, tauri 2.11 does not apply the ACL to app commands at all) and fails `cargo
|
||||
check`/`tauri build` on a missing, misspelled, duplicated or misfiled grant, a `deny-*`, or a
|
||||
hand-written file under `permissions/`. `src/test/capabilities.test.ts` fails if code that runs
|
||||
in a window imports a `tauri-commands.ts` wrapper that window is not granted. Only `_` becomes
|
||||
`-` in the identifier; `permissions/autogenerated/` is generated and ignored, and
|
||||
`gen/schemas/*.json` is regenerated by every build and committed.
|
||||
- **A new window needs its own top-level `capabilities/*.json`; never `webviews`/`remote`;
|
||||
never inline.** `build.rs` only vouches for what `src/command_census.rs` reads — a top-level
|
||||
`capabilities/*.json` file with a `windows` list — so it refuses to build on anything tauri
|
||||
would load that the census can't check: a capability under a subdirectory or written as
|
||||
`.toml`/`.json5`, a `webviews` or `remote` key in a capability file (either widens grants past
|
||||
what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`/any
|
||||
`tauri.<platform>.conf.json`/`TAURI_CONFIG`, or a tauri config in a format it can't parse
|
||||
(JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, swap files) is recognised and skipped
|
||||
rather than refused. Each failure names the check that failed, not just "capabilities do not
|
||||
match generate_handler!". **Known limit:** adding a new `tauri.<platform>.conf.json` to a tree
|
||||
that has already been built once only takes effect on a clean build or in CI — cargo's
|
||||
incremental build has no reason to notice a file that did not exist on the previous build.
|
||||
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
||||
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
|
||||
**label-based** — it does not diff the container's env. If a new setting affects the container's
|
||||
@@ -384,6 +643,264 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||
`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
|
||||
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
|
||||
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
|
||||
|
||||
## Secrets
|
||||
|
||||
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||
for `container/**`.
|
||||
|
||||
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||
catches the real incident (`9b2f4fe`) when replayed.
|
||||
|
||||
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||
for it.
|
||||
|
||||
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||
|
||||
## Settings export/import
|
||||
|
||||
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||
deliberately out of scope — this is not a project backup.
|
||||
|
||||
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||
have carried it along as if it were as inert as a port number, and importing it would have
|
||||
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||
every export already gives it.
|
||||
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||
otherwise shared directory that changes between the two calls.
|
||||
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||
render directly.
|
||||
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||
An import is "restore this environment," so the settings half is a full replace, not a
|
||||
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||
window where a gateway recreation happens against the destination's old keys.
|
||||
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||
alone, a running container would keep serving the old key material indefinitely after an import
|
||||
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||
exactly when it should and no more.
|
||||
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||
third must not read as unqualified success just because the settings half of the import (which
|
||||
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||
- **The imported settings are validated *before* any secret is written, not just before the
|
||||
settings replace.** `apply_settings_import` calls
|
||||
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||
hint of.
|
||||
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||
here can hold a live credential.
|
||||
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||
token when none is already set, so a planted token would otherwise activate silently the next
|
||||
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||
it differs from the destination's current value, since re-importing the same risky configuration
|
||||
is still worth surfacing every time a user confirms an import.
|
||||
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||
as a second layer against the same failure mode.
|
||||
|
||||
## Packaging
|
||||
|
||||
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
|
||||
Windows are untouched.
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
|
||||
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
|
||||
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
|
||||
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
|
||||
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
|
||||
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
|
||||
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
|
||||
metadata and update information**, without which an AppImage manager can adopt the app but never
|
||||
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
|
||||
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
|
||||
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
|
||||
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
|
||||
|
||||
**There is deliberately no Arch package.** A
|
||||
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||
the AppImage is the Arch story.
|
||||
|
||||
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||
|
||||
### Windows code signing
|
||||
|
||||
Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary,
|
||||
the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures"
|
||||
fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship
|
||||
quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not
|
||||
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
|
||||
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
|
||||
secret available there is available to whoever can push a branch. To exercise signing before a
|
||||
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
|
||||
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
|
||||
|
||||
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
|
||||
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
|
||||
**each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri
|
||||
config file with `bundle.windows.signCommand` that the build passes as
|
||||
`cargo tauri build --config`. Nothing is installed on the build VM. To bump
|
||||
a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download.
|
||||
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
|
||||
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
|
||||
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
|
||||
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
|
||||
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
|
||||
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
|
||||
plugins, and signing those would more than double the metered count for no user-visible
|
||||
benefit. If the app ever ships resource DLLs or sidecars, extend the
|
||||
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
|
||||
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
|
||||
`signtool /debug` included), and the job prints it on failure.
|
||||
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the
|
||||
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
|
||||
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
|
||||
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
|
||||
installer, which cannot be unpacked that way, it requires the signing log to show the app
|
||||
binary and the uninstaller were signed.
|
||||
|
||||
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
|
||||
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
|
||||
two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri
|
||||
app"), never by the preview workflow, never echoed, and never on a command line. The repo is
|
||||
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
|
||||
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
|
||||
repo can reach the secrets through a workflow file, so repo write access is the boundary.
|
||||
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
|
||||
release only ever comes from a merged, visible change. The
|
||||
Azure side should hold the rest: an app registration with only the signer role on this one
|
||||
certificate profile, and a client secret with an expiry. Four things are load-bearing:
|
||||
|
||||
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
|
||||
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as
|
||||
SYSTEM, where that waits forever for a browser.
|
||||
- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read
|
||||
that variable — it only sets it, for tauri-build — so a config put there is dropped without an
|
||||
error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied;
|
||||
"Verify signatures" is what exposed it, and it is what would catch a regression.
|
||||
- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is
|
||||
written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64
|
||||
redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64
|
||||
signtool, so they would disagree about where the file is. The workspace is under
|
||||
`systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores
|
||||
the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it
|
||||
signs and the verify step requires a logged signature under that temp directory.
|
||||
- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the
|
||||
`winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's
|
||||
`signtool` (10.0.26100) but no .NET — hence the job-local runtime.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -392,3 +909,10 @@ Frontend tests use Vitest with jsdom environment and React Testing Library. Setu
|
||||
cd app
|
||||
npx vitest run src/path/to/test.test.ts
|
||||
```
|
||||
|
||||
CI runs both suites on every PR: the `test` job in `.gitea/workflows/build-app-preview.yml` does
|
||||
`npm run build`, `npx vitest run` and `cargo test --locked`, in parallel with the platform builds.
|
||||
It is the only place `cargo test` runs on merge, which matters most for the app-command ACL
|
||||
census — an ungranted command compiles and only fails at runtime. `build-app.yml` (releases
|
||||
from `main`) deliberately does not repeat it. The runner is root, so the few Rust tests that
|
||||
exercise file permissions skip themselves there.
|
||||
|
||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,65 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one.
|
||||
|
||||
> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions.
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
|
||||
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||
because nothing installed it — so it will not show up in your app launcher, and
|
||||
running it from a file manager gives you a generic icon in the taskbar.
|
||||
|
||||
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Apps
|
||||
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
```
|
||||
|
||||
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||
copied or moved. To remove the entry again:
|
||||
|
||||
```bash
|
||||
./scripts/install-appimage.sh --uninstall
|
||||
```
|
||||
|
||||
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||
bundle would appear in the menu and then fail to start anything.
|
||||
|
||||
Two follow-ups worth knowing:
|
||||
|
||||
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||
with a newer version under a different filename, re-run the script against the
|
||||
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||
icon cache, not a failed install — see
|
||||
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -128,8 +188,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:**
|
||||
|
||||
@@ -180,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
|
||||
│ │ │ │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────┴────────────────────────────────────────────────────────┤
|
||||
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │
|
||||
│ 2 project(s) · 1 running · 2 terminal(s) Notes │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -205,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
|
||||
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
||||
open you get a welcome screen with Docker/image/project readiness checks.
|
||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
||||
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
|
||||
speech-to-text is enabled.
|
||||
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,7 +288,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 +411,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -471,6 +534,93 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
||||
|
||||
> Toggling this requires stopping and restarting the container to take effect.
|
||||
|
||||
### VPN Support
|
||||
|
||||
When enabled, the container is given the three things a VPN client needs to build a tunnel:
|
||||
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
|
||||
sysctl that WireGuard requires. This is **off by default**.
|
||||
|
||||
The `ip`, `wg` and `iptables` commands ship in the container image so there is something able to use
|
||||
them. If your project's container was created from an older base image it will not have them, and
|
||||
`wg` will simply not be found — **migrating the project onto the current base image** is what picks
|
||||
them up. `sudo apt install iproute2 wireguard-tools iptables` works in the meantime, but lives in
|
||||
the writable layer, so it is undone by a **Reset** and by a migration.
|
||||
|
||||
**This setting makes a tunnel possible; it does not make one.** Nothing is connected, no traffic is
|
||||
redirected, and no tunnel is configured or started on your behalf. Enabling it and expecting the
|
||||
container's traffic to start leaving through a VPN is the most common misreading of what it does —
|
||||
configuring a tunnel and routing traffic into it remains yours to do.
|
||||
|
||||
To make that second half easier, enabling this also installs a **`pia-vpn` skill** into the
|
||||
container's `~/.claude/skills/`, so Claude Code can bring up a Private Internet Access tunnel over
|
||||
WireGuard for you — ask it to connect the VPN and it will. The skill carries the parts that are
|
||||
easy to get wrong (see the DNS note below), and it is removed again when you turn the setting off.
|
||||
It needs your PIA credentials in `~/pia-creds`, two lines, username then password. If you use a
|
||||
different provider, ignore it and set up your own client; nothing else depends on it.
|
||||
|
||||
Like the VPN tooling above, the skill ships in the container image, so a project whose container
|
||||
predates it will not get one by toggling the setting — **migrate the project** and it appears; the
|
||||
migration pre-flight lists it among what you would gain.
|
||||
|
||||
With the setting **off**, a client such as PIA or OpenVPN installs and its daemon starts normally,
|
||||
but the connection attempt **hangs until it times out** — a default container has no tun device to open
|
||||
and no permission to add an interface or a route, and most clients report that as a generic timeout
|
||||
rather than a permissions error.
|
||||
|
||||
Things worth knowing:
|
||||
|
||||
- Tailscale is the exception: in its `--tun=userspace-networking` mode it needs neither the
|
||||
capability nor the device, so leave this off if that is all you want.
|
||||
|
||||
- `NET_ADMIN` applies to the container's **own** network namespace — it cannot touch the host's
|
||||
interfaces. It is not nothing, though: within that namespace anything in the container can set
|
||||
promiscuous mode and add arbitrary addresses, routes and firewall rules on the Docker bridge it
|
||||
shares with your other containers, and it can flush firewall rules that sandbox mode relies on.
|
||||
Grant it per project, to projects that need it.
|
||||
- The **Docker host's** kernel must have the `tun` module available. With Docker Desktop that is
|
||||
the Linux VM, not your own machine. If it is missing, the container is created but fails to
|
||||
**start**, with an error naming `/dev/net/tun` and pointing back at this setting.
|
||||
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the
|
||||
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
|
||||
- **No tunnel survives a restart.** The network namespace is built fresh every time the container
|
||||
starts, and there is no service manager inside to reconnect anything. Leftover state under `/run`
|
||||
makes it *look* like the tunnel is still configured — that directory is in the container's
|
||||
writable layer, so it is simply still there after a stop/start, and `docker commit` carries it
|
||||
into the snapshot that a recreation is built from. Either way the interface and its routes are
|
||||
gone and traffic goes out your real address again, with no error and nothing visibly different.
|
||||
Re-establish it after every start, and check rather than assume.
|
||||
- **A full tunnel breaks DNS unless the client is told to leave private ranges alone.** Your
|
||||
resolver is whatever `/etc/resolv.conf` says, and if that address is outside the container's own
|
||||
subnet then a default route of `0.0.0.0/0` — or a `0.0.0.0/1` plus `128.0.0.0/1` pair — captures
|
||||
it and sends every lookup into a tunnel that cannot carry it. Under Docker Desktop it is
|
||||
`192.168.65.7`, which is exactly that case; on a user-defined Docker network it is `127.0.0.11`,
|
||||
which is loopback and unaffected. Check yours rather than assuming. The symptom when it bites is
|
||||
total: Claude Code reports it cannot connect, because it cannot resolve `api.anthropic.com`.
|
||||
Route `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `169.254.0.0/16` via the original
|
||||
gateway — and give the tunnel a resolver it can actually reach, normally the VPN provider's own,
|
||||
or you have a tunnel that leaks every DNS query outside itself. Also pin the VPN endpoint's own
|
||||
address via the original gateway, or the tunnel's encrypted packets try to route through the
|
||||
tunnel. Note that a health check which fetches an IP literal such as `1.1.1.1` passes cleanly
|
||||
while DNS is broken — resolve a name instead.
|
||||
- **Delete a client's key material when you tear a tunnel down.** Anything written under `/run` is
|
||||
in the container's writable layer, and recreating or migrating the project runs `docker commit`
|
||||
over it — so a WireGuard private key left there gets baked into the project's snapshot image and
|
||||
copied forward from then on. This is not hypothetical; it has already happened here.
|
||||
- **Strip the `DNS =` line from a provider's `.conf` before `wg-quick up`.** Every commercial
|
||||
provider ships one, and `wg-quick` hands it to `resolvconf`, which is not installed — so it fails
|
||||
at `resolvconf: command not found` and deletes the interface again. This happens before any
|
||||
routing, so it takes **split tunnels down too**. Set the resolver another way instead, or drive
|
||||
`wg` and `ip route` directly rather than going through `wg-quick`.
|
||||
- **`wg-quick` full tunnels additionally need `xt_CONNMARK` from the host kernel.** WSL2 kernels
|
||||
before 6.6 do not have it and a container cannot load one — on Windows, `wsl --update` moves you
|
||||
to a current kernel, which does. Failing that, add the routes yourself with `ip route`, which
|
||||
needs no firewall backend on any platform. Note this is the *second* hurdle: clear the `DNS =`
|
||||
one above first, or you will not reach this.
|
||||
|
||||
> This setting can only be changed when the container is stopped. Capabilities and devices are
|
||||
> fixed when a container is created, so toggling it recreates the container on the next start.
|
||||
> Recreation preserves the home and `.claude` volumes — it is not a Reset.
|
||||
|
||||
### Mission Control
|
||||
|
||||
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
||||
@@ -525,18 +675,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||
real choice — it overrides a global On, which a project could not previously do.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||
> when the container starts. Changing one stops and recreates the container.
|
||||
>
|
||||
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||
> Overview tab first. The other switches work on any image.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
@@ -702,6 +861,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
|
||||
@@ -1052,20 +1224,89 @@ Programs inside the container can copy text to your host clipboard. When a conta
|
||||
|
||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||
|
||||
### Jump to Current
|
||||
### Scrolling
|
||||
|
||||
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
||||
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
|
||||
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
|
||||
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
|
||||
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
|
||||
for them to act on.
|
||||
|
||||
### When the mouse stops working
|
||||
|
||||
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
|
||||
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
|
||||
you cannot select text, and stray characters can appear as you move the pointer.
|
||||
|
||||
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
|
||||
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
|
||||
container — only the terminal's own state is reset.
|
||||
|
||||
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
|
||||
the button is showing most of the time you are in one. It is there for when a program exits
|
||||
without handing the mouse back and the terminal is left stuck; releasing while a program is still
|
||||
running just takes the mouse away from that program.
|
||||
|
||||
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
|
||||
macOS.
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
||||
- **Download** any file to your host machine via the **Download** button on each file entry
|
||||
- **Upload file** from your host into the current container directory
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
viewer
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates.
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
|
||||
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
|
||||
way are judged.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1111,10 +1352,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks (In the Container)
|
||||
### Creating Tasks
|
||||
|
||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
||||
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||
form for the name, the schedule and the prompt.
|
||||
|
||||
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
@@ -1139,13 +1384,23 @@ triple-c-scheduler list # List all tasks
|
||||
triple-c-scheduler enable --id abc123 # Enable a task
|
||||
triple-c-scheduler disable --id abc123 # Disable a task
|
||||
triple-c-scheduler remove --id abc123 # Delete a task
|
||||
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
||||
triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
|
||||
triple-c-scheduler status # What is running right now, and for how long
|
||||
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
|
||||
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||
triple-c-scheduler notifications # View completion notifications
|
||||
triple-c-scheduler notifications --clear # Clear notifications
|
||||
```
|
||||
|
||||
`list` carries a status column, and the Automation tab marks a task **Running** with
|
||||
its elapsed time, so a triggered run is visible rather than silent.
|
||||
|
||||
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
|
||||
writes its answer in one go when it finishes, so a healthy run shows nothing but its
|
||||
header for as long as it is thinking. `status` is what distinguishes a slow run from
|
||||
a dead one — it reports the run only while the runner's process is genuinely alive.
|
||||
|
||||
### Cron Schedule Format
|
||||
|
||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||
@@ -1196,9 +1451,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
||||
| **Ctrl+Shift+V** | Paste |
|
||||
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
||||
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
||||
| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it |
|
||||
| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down |
|
||||
|
||||
Everything else goes straight through to the program running in the container.
|
||||
|
||||
> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs
|
||||
> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is
|
||||
> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and
|
||||
> Shift+Enter there submits the line as it always has.
|
||||
>
|
||||
> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+**
|
||||
> key beside **Enter** on the mobile key row for devices with no Shift.
|
||||
|
||||
---
|
||||
|
||||
## What's Inside the Container
|
||||
@@ -1215,6 +1480,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| ruff | Latest | Python linter/formatter |
|
||||
| Rust | Stable | Rust development (via rustup) |
|
||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||
| iproute2, WireGuard tools, iptables | Latest | Building a tunnel (when VPN Support is enabled) |
|
||||
| git | Latest | Version control |
|
||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||
| AWS CLI | v2 | AWS services and Bedrock |
|
||||
@@ -1304,8 +1570,18 @@ your machine (anything that isn't `http`/`https`).
|
||||
|
||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
||||
*container's*. Enable the
|
||||
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
|
||||
*container's*.
|
||||
|
||||
Two ways out, in order of least effort:
|
||||
|
||||
1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**.
|
||||
The page opens in a browser *inside* the container, so the callback never has to cross to the
|
||||
host. This needs no auth bridge — only a running container with Playwright installed (Project
|
||||
Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button.
|
||||
2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home →
|
||||
**Config** → **Runtime** → **Auth bridge** — and try again. It can be switched on while the
|
||||
container is running. Check the indicator beside it: **Port conflict** means the host port was
|
||||
already taken and the callback still will not arrive.
|
||||
|
||||
For Claude specifically, the simpler answer is usually
|
||||
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
||||
@@ -1342,3 +1618,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||
</picture>
|
||||
|
||||
# Triple-C (Claude-Code-Container)
|
||||
|
||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||
|
||||
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||
|
||||
| Document | For |
|
||||
|---|---|
|
||||
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||
@@ -29,6 +55,13 @@ two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a termi
|
||||
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||
thing is current at a time.
|
||||
|
||||
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||
carries dropped *file paths*.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
@@ -39,31 +72,47 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
| `Ctrl+Shift+W` | Close the active tab |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||
|
||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||
why moving a tab takes Shift as well.
|
||||
|
||||
Terminal-scoped keys are handled in `TerminalView.tsx`:
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is |
|
||||
| `Ctrl+Shift+M` | Toggle speech-to-text recording |
|
||||
| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting |
|
||||
| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked |
|
||||
|
||||
`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for
|
||||
VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes
|
||||
are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for
|
||||
devices with no Shift.
|
||||
|
||||
### Project Home
|
||||
|
||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
||||
Per-project configuration lives in the Config tab rather than in modals.
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||
progress modal.
|
||||
|
||||
### Permission Modes
|
||||
## Permission Modes
|
||||
|
||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||
@@ -87,15 +136,196 @@ back into flags for its headless `claude -p` run. Because it travels as containe
|
||||
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||
forces that).
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
## Containers
|
||||
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
### Container Lifecycle
|
||||
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||
|
||||
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||
image.
|
||||
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
|
||||
### Base-Image Migration
|
||||
|
||||
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||
|
||||
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||
"stale".
|
||||
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||
skills, transcripts and scheduler tasks simply re-attach.
|
||||
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||
report.
|
||||
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||
`$HOME` during a migrated session survives it.
|
||||
|
||||
### Mounts
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
|
||||
### Corporate CA Certificates
|
||||
|
||||
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||
every start — so it survives recreation, migration and Reset.
|
||||
|
||||
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||
sees a directory.
|
||||
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||
rather than failing the start.
|
||||
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||
so turning the feature off has to undo, not merely stop.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Models and Authentication
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Model Gateway (LiteLLM sibling container)
|
||||
|
||||
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||
lifecycle, including auto-start with the app.
|
||||
|
||||
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||
**always** set, because LiteLLM without one accepts any key.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
|
||||
## Bridges to the Host
|
||||
|
||||
### URL Relay (host browser)
|
||||
|
||||
@@ -175,96 +405,87 @@ recreates the container. The poller stops on its own when the container stops.
|
||||
unauthenticated service inside the container, so widening those addresses would publish container
|
||||
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
### Browser View
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||
per project.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||
iframe while popped out, and both viewers can drive the browser.
|
||||
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||
continuously and each event costs a container exec.
|
||||
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||
recreation *and* migration.
|
||||
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
### Host File Transfers
|
||||
|
||||
### Container Lifecycle
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||
canonical ones included, so this closes the inbound direction and not both.
|
||||
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||
save dialog parses its name box as a path, and a container can name a file
|
||||
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||
cannot be a path on any platform this ships to.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
### Mounts
|
||||
## Inside a Project
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
### Container Introspection (Capability Tiles)
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
|
||||
### Mission Control Integration
|
||||
|
||||
@@ -289,87 +510,117 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||
- **Port**: Default `9876` (configurable)
|
||||
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||
- **Language**: Optional language hint for transcription
|
||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||
|
||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Frontend — layout and projects
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
|
||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
|
||||
### Frontend — Project Home
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
|
||||
|
||||
### Frontend — settings, terminal and hooks
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||
|
||||
### Container and packaging
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
||||
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||
|
||||
## CSS / Styling Notes
|
||||
|
||||
@@ -382,7 +633,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
**Base**: Ubuntu 24.04
|
||||
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
|
||||
@@ -406,4 +657,10 @@ The libraries are the opposite — a runtime `apt-get install` lands in the cont
|
||||
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
||||
against the new base). Baking one and not the other puts each half where it already persists.
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the `triple-c-home-{projectId}`
|
||||
volume, so after a project's *first* start the image's copy of that directory is masked permanently.
|
||||
A change made under `/home/claude` in the Dockerfile reaches **new projects only** — with or without
|
||||
a base-image migration. Anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or
|
||||
must be seeded by `entrypoint.sh` on every start.
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
|
||||
@@ -26,24 +26,39 @@ 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
|
||||
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
file drag-drop, STT), the web terminal, and workspace backup.
|
||||
|
||||
---
|
||||
|
||||
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
||||
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||
|
||||
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
|
||||
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
|
||||
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
|
||||
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
|
||||
|
||||
### bollard (Docker API)
|
||||
|
||||
@@ -412,13 +415,12 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -436,7 +438,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 +466,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 +506,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Triple-C</title>
|
||||
</head>
|
||||
|
||||
@@ -8,10 +8,24 @@
|
||||
"name": "triple-c",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.4",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@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",
|
||||
@@ -414,6 +428,204 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
|
||||
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-rust": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
|
||||
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/rust": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
|
||||
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/legacy-modes": {
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
|
||||
"integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.13",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -1056,6 +1268,123 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz",
|
||||
"integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz",
|
||||
"integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
|
||||
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
|
||||
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2001,15 +2330,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",
|
||||
@@ -2533,6 +2853,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
@@ -3609,6 +3935,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -3935,6 +4267,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -9,13 +9,28 @@
|
||||
"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": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.4",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C application icon, small-size variant</title>
|
||||
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||
survive being resampled down to 16 px. See build-icons.py. -->
|
||||
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||
stroke="#58A6FF" stroke-width="14"/>
|
||||
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 812 B |
@@ -8,6 +8,41 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -310,6 +363,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -569,6 +631,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,7 +1218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -3394,7 +3525,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3743,7 +3874,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4654,22 +4785,6 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.0"
|
||||
@@ -4782,7 +4897,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5165,6 +5280,8 @@ dependencies = [
|
||||
name = "triple-c"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
@@ -5187,10 +5304,11 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5304,6 +5422,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5689,7 +5817,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"] }
|
||||
@@ -37,6 +36,13 @@ tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
zeroize = "1"
|
||||
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
|
||||
# container. Already in the tree transitively (reqwest), and the point of
|
||||
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
@@ -45,6 +51,8 @@ tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
@@ -1,3 +1,208 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
|
||||
//! to build unless every registered command is granted in exactly one capability file — the
|
||||
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
|
||||
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
|
||||
//! could call any command.
|
||||
//!
|
||||
//! Because the census can only vouch for what it reads, the build also stops on any capability
|
||||
//! tauri would load that the census does not: anything in `capabilities/` other than a
|
||||
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
|
||||
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
|
||||
//!
|
||||
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
|
||||
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
|
||||
|
||||
#[path = "src/command_census.rs"]
|
||||
mod command_census;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Stops the build. `what` names the check that failed, so a malformed capability file, a
|
||||
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
|
||||
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{what} ({} problem{}):",
|
||||
problems.len(),
|
||||
if problems.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
for p in problems {
|
||||
eprintln!(" - {p}");
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("{hint}");
|
||||
eprintln!();
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
|
||||
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
|
||||
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
|
||||
src/command_census.rs can only vouch for what it reads.";
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.expect("a directory entry has a file name")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
|
||||
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=src/command_census.rs");
|
||||
|
||||
let lib_rs = std::fs::read_to_string("src/lib.rs")
|
||||
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
|
||||
let Some(commands) = command_census::registered_commands(&lib_rs) else {
|
||||
fail(
|
||||
"missing generate_handler! block",
|
||||
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
|
||||
.to_string()],
|
||||
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
|
||||
);
|
||||
};
|
||||
|
||||
check_tauri_config();
|
||||
let files = read_capabilities();
|
||||
|
||||
let problems = command_census::check(&commands, &files);
|
||||
if !problems.is_empty() {
|
||||
fail(
|
||||
"capabilities do not match generate_handler!",
|
||||
&problems,
|
||||
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
|
||||
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
|
||||
capabilities/default.json. See src/command_census.rs.",
|
||||
);
|
||||
}
|
||||
|
||||
prune_permissions(&commands);
|
||||
|
||||
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
|
||||
// there is no owned form; leaking is fine in a process that exits right after.
|
||||
let leaked: Vec<&'static str> = commands
|
||||
.into_iter()
|
||||
.map(|c| &*Box::leak(c.into_boxed_str()))
|
||||
.collect();
|
||||
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
|
||||
let attributes = tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
|
||||
if let Err(error) = tauri_build::try_build(attributes) {
|
||||
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
|
||||
println!("{error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
|
||||
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
|
||||
/// capability could still reference (and the build would pass). Delete only the stale files:
|
||||
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
|
||||
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
|
||||
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
|
||||
/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
|
||||
/// loads and which is skipped (see `command_census::is_os_junk`).
|
||||
fn prune_permissions(commands: &[String]) {
|
||||
let root = Path::new("permissions");
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries {
|
||||
let path = entry.expect("readable entry in permissions/").path();
|
||||
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
|
||||
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
|
||||
continue;
|
||||
}
|
||||
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
|
||||
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
|
||||
let file = file.expect("readable entry").path();
|
||||
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let live = file.extension().is_some_and(|e| e == "toml")
|
||||
&& commands.iter().any(|c| c == stem);
|
||||
if !live {
|
||||
std::fs::remove_file(&file)
|
||||
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
"hand-written permission",
|
||||
&[format!(
|
||||
"{} is not generated by build.rs; hand-written permissions are not allowed \
|
||||
(every grant is a bare allow-* string in a capability file)",
|
||||
path.display()
|
||||
)],
|
||||
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
|
||||
an app command is granted by listing allow-<command> in a capability file.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every capability tauri will load, read the way the census reads it — or the build stops.
|
||||
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
|
||||
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
|
||||
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
|
||||
let mut files = Vec::new();
|
||||
let mut stray = Vec::new();
|
||||
let mut invalid = Vec::new();
|
||||
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
|
||||
let path = entry.expect("readable entry in capabilities/").path();
|
||||
let name = file_name(&path);
|
||||
let is_file = path.is_file();
|
||||
if is_file && command_census::is_os_junk(&name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
|
||||
stray.push(problem);
|
||||
continue;
|
||||
}
|
||||
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
match command_census::capability_file(&name, &json) {
|
||||
Ok(file) => files.push(file),
|
||||
Err(problem) => invalid.push(problem),
|
||||
}
|
||||
}
|
||||
stray.sort();
|
||||
if !stray.is_empty() {
|
||||
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
|
||||
}
|
||||
invalid.sort();
|
||||
if !invalid.is_empty() {
|
||||
fail("invalid capability file", &invalid, LAYOUT_HINT);
|
||||
}
|
||||
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
files
|
||||
}
|
||||
|
||||
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
|
||||
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
|
||||
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
|
||||
fn check_tauri_config() {
|
||||
let mut problems = Vec::new();
|
||||
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
|
||||
let path = entry.expect("readable entry in src-tauri/").path();
|
||||
let name = file_name(&path);
|
||||
match command_census::tauri_config_file(&name) {
|
||||
None => {}
|
||||
Some(false) => problems.push(format!(
|
||||
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
|
||||
declare capabilities it cannot see"
|
||||
)),
|
||||
Some(true) => {
|
||||
let json =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
problems.extend(command_census::tauri_config_problem(&name, &json));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(json) = std::env::var("TAURI_CONFIG") {
|
||||
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
|
||||
}
|
||||
problems.sort();
|
||||
if !problems.is_empty() {
|
||||
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"identifier": "file-viewer",
|
||||
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
|
||||
"windows": ["file-viewer-*"],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-destroy",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"allow-viewer-get-state",
|
||||
"allow-viewer-choose-file",
|
||||
"allow-viewer-read-file",
|
||||
"allow-viewer-poll-file",
|
||||
"allow-viewer-write-file"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 16 KiB |
@@ -82,6 +82,10 @@ pub struct BridgedPort {
|
||||
pub family: PortFamily,
|
||||
/// RFC 3339 timestamp of when the host listener was bound.
|
||||
pub bridged_at: String,
|
||||
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||
/// port still works, but not for a client that insists on `::1` — see
|
||||
/// [`tunnel::PortForward::ipv6_warning`].
|
||||
pub ipv6_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// A loopback listener that was discovered but could not be bridged.
|
||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
||||
port: f.port,
|
||||
family: f.family,
|
||||
bridged_at: f.bridged_at.clone(),
|
||||
ipv6_warning: f.ipv6_warning.clone(),
|
||||
})
|
||||
.collect(),
|
||||
conflicts: self
|
||||
@@ -329,7 +334,10 @@ async fn poll_loop(
|
||||
Ok(text) => {
|
||||
exec_failures = 0;
|
||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||
let skip = skipped_ports(&project);
|
||||
// Re-read every tick: a project can gain a port mapping and the
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
@@ -372,24 +380,101 @@ async fn poll_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
/// Every port this project's bridge must not take.
|
||||
///
|
||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||
/// loopback listeners another feature owns and exposes on its own,
|
||||
/// authenticated terms.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||
/// enumerated here. Four sources:
|
||||
///
|
||||
/// 1. **This project's own published ports** — a container port that Docker
|
||||
/// already publishes has a host-side path, and the mapping's host port is a
|
||||
/// binding we must not fight over.
|
||||
/// 2. **Every other project's published host ports.** The container names the
|
||||
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||
/// the host end of another project's mapping is reserved: its container end
|
||||
/// is a number inside a different network namespace and means nothing here.
|
||||
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||
/// and the web terminal. All three are off by default and bind on demand, so
|
||||
/// first-come would win: a container that binds container-loopback 4000
|
||||
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||
/// anything on the host dialling 4000 — including *other project
|
||||
/// containers*, which reach the gateway by host address — is talking to the
|
||||
/// squatting container instead. The web terminal is the worst of the three,
|
||||
/// because its access token travels in the URL query. Both the *configured*
|
||||
/// port and the shipped default are reserved: the configured one is what the
|
||||
/// service will bind next, and the default is what it falls back to for a
|
||||
/// fresh profile or a settings file that failed to parse.
|
||||
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||
/// terms.
|
||||
///
|
||||
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||
/// policy is unit-testable without a store, a container or an app handle.
|
||||
fn skipped_ports(
|
||||
project: &crate::models::Project,
|
||||
all_projects: &[crate::models::Project],
|
||||
settings: &crate::models::AppSettings,
|
||||
) -> HashSet<u16> {
|
||||
let mut skip: HashSet<u16> = project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect();
|
||||
|
||||
// Other projects: host end only.
|
||||
skip.extend(
|
||||
all_projects
|
||||
.iter()
|
||||
.filter(|p| p.id != project.id)
|
||||
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||
);
|
||||
|
||||
skip.extend(app_service_host_ports(settings));
|
||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||
skip
|
||||
}
|
||||
|
||||
/// Current app settings, or defaults if the state is not reachable.
|
||||
///
|
||||
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||
/// worse than one that falls back to the shipped port numbers — which are what
|
||||
/// the services use anyway until someone changes them.
|
||||
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||
use tauri::Manager;
|
||||
app.try_state::<crate::AppState>()
|
||||
.map(|state| state.settings_store.get())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Host ports this app's own sibling services bind, configured value and
|
||||
/// shipped default alike.
|
||||
///
|
||||
/// Read off the settings models rather than restated as literals here: a
|
||||
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||
/// and the failure mode of drift is a reservation that no longer covers the
|
||||
/// service it was written for.
|
||||
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||
use crate::models::{SttSettings, WebTerminalSettings};
|
||||
|
||||
vec![
|
||||
// LiteLLM gateway (`docker/gateway.rs`).
|
||||
settings.gateway.port,
|
||||
crate::models::default_gateway_port(),
|
||||
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||
settings.stt.port,
|
||||
SttSettings::default().port,
|
||||
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||
// its access token is in the URL query.
|
||||
settings.web_terminal.port,
|
||||
WebTerminalSettings::default().port,
|
||||
]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -586,7 +671,7 @@ async fn emit_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{PortMapping, Project, ProjectPath};
|
||||
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||
let mut p = Project::new(
|
||||
@@ -607,9 +692,14 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// The common case: one project, no siblings, stock settings.
|
||||
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_already_published_by_docker_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
assert!(skip.contains(&3000));
|
||||
// Both ends of an asymmetric mapping are off limits: the container port
|
||||
// is already reachable, and the host port is Docker's binding.
|
||||
@@ -619,20 +709,96 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||
let settings = AppSettings::default();
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skip_for(&project);
|
||||
|
||||
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||
expected.extend(RESERVED_HOST_PORTS);
|
||||
expected.extend(app_service_host_ports(&settings));
|
||||
assert_eq!(skip, expected);
|
||||
|
||||
// The ranges and the service ports are disjoint, so nothing above is
|
||||
// accidentally counting the same port twice.
|
||||
assert_eq!(
|
||||
skip.len(),
|
||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||
RESERVED_CONTAINER_PORTS.clone().count()
|
||||
+ RESERVED_HOST_PORTS.clone().count()
|
||||
+ 3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_apps_own_host_services_are_never_taken() {
|
||||
// The bug this guards: the reserved set used to cover only the
|
||||
// browser-view ranges and this project's own mappings, so a container
|
||||
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||
// door before they could bind it.
|
||||
let settings = AppSettings::default();
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
|
||||
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||
|
||||
// The shipped defaults, spelled out once so a change to any of them is
|
||||
// a change to this assertion and not a silent narrowing.
|
||||
assert!(skip.contains(&4000));
|
||||
assert!(skip.contains(&9876));
|
||||
assert!(skip.contains(&7681));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.gateway.port = 4321;
|
||||
settings.stt.port = 9000;
|
||||
settings.web_terminal.port = 8443;
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||
|
||||
for port in [4321, 9000, 8443] {
|
||||
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||
}
|
||||
// The default stays reserved too: it is what the service falls back to
|
||||
// for a fresh profile or an unparseable settings file, so leaving it
|
||||
// open is leaving the same squat available one restart later.
|
||||
for port in [4000, 9876, 7681] {
|
||||
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_projects_published_host_port_is_not_stolen() {
|
||||
// The container names the *host* port. Without this, project A's
|
||||
// container listening on 8080 takes the host 8080 that project B
|
||||
// publishes on — the bridge wins the race whenever B's container is not
|
||||
// running yet.
|
||||
let mine = project_with_mappings(vec![]);
|
||||
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||
theirs.id = format!("{}-other", mine.id);
|
||||
|
||||
let skip = skipped_ports(
|
||||
&mine,
|
||||
&[mine.clone(), theirs.clone()],
|
||||
&AppSettings::default(),
|
||||
);
|
||||
assert!(skip.contains(&8080), "another project's host port");
|
||||
// …but not the other project's *container* port: that number lives in a
|
||||
// different network namespace and means nothing on this host, and
|
||||
// reserving it would refuse a legitimate login callback for no reason.
|
||||
assert!(!skip.contains(&3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_host_ports_are_never_taken() {
|
||||
// The bridge binds *host* ports chosen by the container, so without
|
||||
// this it can take the port the browser-view proxy will want later —
|
||||
// that pane binds on demand, so first-come would win.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_HOST_PORTS {
|
||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||
}
|
||||
@@ -688,14 +854,14 @@ mod tests {
|
||||
// Mirroring these would publish an ungated second door to the
|
||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||
// token-checking listener.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_CONTAINER_PORTS {
|
||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||
|
||||
// Reservations coexist with Docker's own published ports.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||
assert!(skip.contains(&3000));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,48 @@
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
//!
|
||||
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||
//! nothing in that chain can be taught to present a token — so there is no path
|
||||
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||
//! the container-side listener. That includes **any web page the user has open**,
|
||||
//! which can port-scan loopback from script.
|
||||
//!
|
||||
//! Two things narrow that, and neither is a substitute for the other:
|
||||
//!
|
||||
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||
//! ports while its container is running.
|
||||
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||
//! what an OAuth redirect is.
|
||||
//!
|
||||
//! The residual risk, stated plainly rather than papered over: a client that
|
||||
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||
//! probe must all still work) but also any browser predating fetch metadata
|
||||
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||
//! port: if the container has something more interesting than a throwaway OAuth
|
||||
//! listener on loopback, a same-machine caller reaches it.
|
||||
//!
|
||||
//! ## Bounds
|
||||
//!
|
||||
//! Every accepted connection costs a `docker exec`, and the number of
|
||||
//! connections is decided by whoever can reach the port. So each forward caps
|
||||
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||
//! exec is created), and drops a connection the container has gone quiet on
|
||||
//! ([`IDLE_TIMEOUT`]).
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::StreamExt;
|
||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
||||
/// only needs to not be pathological.
|
||||
const PUMP_BUF: usize = 16 * 1024;
|
||||
|
||||
/// Concurrent connections one forwarded port will carry.
|
||||
///
|
||||
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||
/// connection, occasionally a handful; this is generous for that and still a
|
||||
/// bound the engine will not notice.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
/// How long an accepted connection has to send its first byte before it is
|
||||
/// dropped, *without* a `docker exec` ever being created for it.
|
||||
///
|
||||
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||
/// that is the trade, and it is the only protocol shape affected.
|
||||
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How long a live connection may go with nothing coming back from the container
|
||||
/// before it is torn down. Generous, because a bridged port is not always a
|
||||
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||
/// exec forever.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||
const MAX_HEAD: usize = 32 * 1024;
|
||||
|
||||
/// How long the rest of a request head has, once the first line has identified
|
||||
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||
/// child running.
|
||||
struct AbortOnDrop(JoinHandle<()>);
|
||||
@@ -52,6 +124,15 @@ pub struct PortForward {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
pub bridged_at: String,
|
||||
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||
///
|
||||
/// A half-bound forward is the one failure mode that looks like a success:
|
||||
/// the status says the port is bridged, and a browser that resolves
|
||||
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||
/// so, rather than being logged at debug where nobody sees it.
|
||||
pub ipv6_warning: Option<String>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -86,16 +167,31 @@ 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),
|
||||
let (v6, ipv6_warning) =
|
||||
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => (Some(l), None),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||
// 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
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||
localhost to ::1 without falling back will not reach this port.",
|
||||
port, e
|
||||
)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,6 +202,7 @@ impl PortForward {
|
||||
port,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ use crate::AppState;
|
||||
/// non-`Running` status carrying an explanation rather than an error, so the
|
||||
/// pane always has something specific to say. This is host-side only — no
|
||||
/// container recreation is involved either way.
|
||||
///
|
||||
/// Either way the choice is persisted, so it survives an app restart. This is
|
||||
/// the only caller allowed to write `false`: every other path to
|
||||
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
|
||||
/// is a teardown rather than the user changing their mind. Enabling persists
|
||||
/// inside `start`, which is the single funnel for it.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_enabled(
|
||||
project_id: String,
|
||||
@@ -23,9 +29,32 @@ pub async fn set_browser_view_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
if !enabled {
|
||||
// Persist first, then tear down: the supervisor's own teardown emit
|
||||
// reads this flag back out of the store, and reading it mid-stop would
|
||||
// announce a view that is going away as still enabled.
|
||||
//
|
||||
// But the write's outcome is a *value*, not a branch. A `?` here meant
|
||||
// that a store with no such project record returned early and
|
||||
// `manager().stop()` never ran, leaving the supervisor, the proxy and
|
||||
// the host port up for a project that, as far as the user is concerned,
|
||||
// just had its view switched off. That state is not hypothetical while
|
||||
// a session is live — the supervisor's own `store.get()` check in
|
||||
// [`crate::browser_view`] exists because a record can go away
|
||||
// underneath it — and before the flag was persisted at all, turning the
|
||||
// view off always tore the session down.
|
||||
let persisted = state
|
||||
.projects_store
|
||||
.set_browser_view_enabled(&project_id, false);
|
||||
// Awaits the supervisor, so the host port is released before we return.
|
||||
manager().stop(&project_id).await;
|
||||
return Ok(manager().status(&project_id).await);
|
||||
//
|
||||
// A failed write is still reported rather than logged and swallowed.
|
||||
// The resources are gone either way by this point, so surfacing it
|
||||
// costs nothing that matters, and the failure it describes is one the
|
||||
// user needs: the stored flag still says *enabled*, so the view comes
|
||||
// back by itself on the next launch. Returning `Ok` would be a claim
|
||||
// about persistence that isn't true.
|
||||
tear_down_then_report(persisted, manager().stop(&project_id)).await?;
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
@@ -40,10 +69,31 @@ pub async fn set_browser_view_enabled(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Current status. Cheap: reads in-process state only, never the container.
|
||||
/// Await `teardown`, then report `persisted`.
|
||||
///
|
||||
/// Trivial on purpose, and split out for one reason: it is the whole rule the
|
||||
/// disable path of [`set_browser_view_enabled`] has to obey — the teardown is
|
||||
/// unconditional, and a failed persist surfaces only after it has run — and as
|
||||
/// a free function that rule can be tested without a live `AppState`.
|
||||
async fn tear_down_then_report(
|
||||
persisted: Result<(), String>,
|
||||
teardown: impl std::future::Future<Output = ()>,
|
||||
) -> Result<(), String> {
|
||||
teardown.await;
|
||||
persisted
|
||||
}
|
||||
|
||||
/// Current status. Cheap: the session map in this process plus the stored flag,
|
||||
/// never the container.
|
||||
///
|
||||
/// The two are independent on purpose — this is what the pane reads on mount,
|
||||
/// and after an app restart the honest answer is "enabled, nothing running".
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id).await)
|
||||
pub async fn get_browser_view_status(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
|
||||
}
|
||||
|
||||
/// Probe the container for Playwright without starting anything.
|
||||
@@ -110,7 +160,9 @@ pub async fn open_browser_view_popout(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||
return Err(
|
||||
"The browser view isn't running. Start it before opening it in its own window."
|
||||
@@ -209,7 +261,9 @@ pub async fn open_page_in_container_browser(
|
||||
// the user to go and press Start in the Browser tab themselves — and from
|
||||
// the terminal's URL prompt, with no indication that was even needed.
|
||||
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if status.state != BrowserViewState::Running {
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
@@ -229,7 +283,9 @@ pub async fn open_page_in_container_browser(
|
||||
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||
// window of its own or it lands somewhere the user isn't looking.
|
||||
if show_window {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
let name = state
|
||||
.projects_store
|
||||
@@ -311,6 +367,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
|
||||
Ok(popout::match_window(&project_id))
|
||||
}
|
||||
|
||||
/// The project's stored browser-view opt-in.
|
||||
///
|
||||
/// The manager holds no copy of this — see
|
||||
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
|
||||
/// status call reads it here, the way `get_auth_bridge_status` does. A project
|
||||
/// that has gone away reads as off, which is the only answer that can be given
|
||||
/// about a record that no longer exists.
|
||||
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
|
||||
state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled)
|
||||
}
|
||||
|
||||
/// The project's container, or a sentence saying why there isn't one.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
@@ -344,3 +414,43 @@ async fn running_container(
|
||||
}
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// The regression: turning the view off must not leave the supervisor, the
|
||||
/// proxy and the host port running just because the project record could
|
||||
/// not be written — which is exactly what a missing record did.
|
||||
#[tokio::test]
|
||||
async fn a_failed_persist_does_not_skip_the_teardown() {
|
||||
let torn_down = AtomicBool::new(false);
|
||||
let result = tear_down_then_report(Err("Project x not found".to_string()), async {
|
||||
torn_down.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
torn_down.load(Ordering::SeqCst),
|
||||
"the session must be torn down even when the store write failed"
|
||||
);
|
||||
assert_eq!(
|
||||
result.err().as_deref(),
|
||||
Some("Project x not found"),
|
||||
"and the write failure must still reach the caller, not be swallowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_persist_reports_success_after_the_teardown() {
|
||||
let torn_down = AtomicBool::new(false);
|
||||
let result = tear_down_then_report(Ok(()), async {
|
||||
torn_down.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(torn_down.load(Ordering::SeqCst));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,11 +194,24 @@ impl BrowserTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `channel` a launch check must pass. `None` means the bundled build.
|
||||
fn channel(self) -> Option<&'static str> {
|
||||
/// Every `channel` a launch check must pass, comma-separated, where
|
||||
/// `default` means "no channel — the bundled build".
|
||||
///
|
||||
/// Chromium is checked twice because the two consumers of this install do
|
||||
/// not launch the same binary. A script calling `chromium.launch()` with
|
||||
/// no channel gets `chromium-headless-shell`; the viewer reads
|
||||
/// `~/.playwright/cli.config.json`, which pins channel
|
||||
/// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
|
||||
/// build — a separate download under the same `install chromium`.
|
||||
///
|
||||
/// Checking only the first is how a container reaches "verified" and then
|
||||
/// fails in the pane with `Browser "chrome-for-testing" is not installed`.
|
||||
/// Observed on a real project, where a stale `chromium-1217` satisfied the
|
||||
/// headless-shell launch while the viewer wanted `chromium-1237`.
|
||||
fn channels(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chromium => None,
|
||||
Self::Chrome => Some("chrome"),
|
||||
Self::Chromium => "default,chrome-for-testing",
|
||||
Self::Chrome => "chrome",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,7 +248,7 @@ pub async fn install_packages(
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
||||
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -246,9 +259,15 @@ pub async fn install_packages(
|
||||
|
||||
// Second, `playwright` at the version the viewer package pins — see
|
||||
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
|
||||
//
|
||||
// The viewer package is named *again* here. It is already installed, so
|
||||
// this adds no work, but omitting it is what made npm prune it back out —
|
||||
// see the note on `npm_install`. The pin can only be read after the first
|
||||
// install has written the manifest, which is why this stays two commands
|
||||
// rather than one.
|
||||
let spec = pinned_playwright_spec(container_id).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &spec).await?;
|
||||
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -290,7 +309,7 @@ pub async fn install_packages(
|
||||
})
|
||||
}
|
||||
|
||||
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`.
|
||||
/// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
|
||||
///
|
||||
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
||||
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
||||
@@ -298,13 +317,24 @@ pub async fn install_packages(
|
||||
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
|
||||
/// all), but if a future release brings the browser download back, this step
|
||||
/// must stay small and the download must stay the step the user asked for.
|
||||
///
|
||||
/// **Every package that must survive has to appear in `specs`.** `--no-save`
|
||||
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
|
||||
/// npm with the command line as its only statement of what the tree should
|
||||
/// contain, and npm ≥7 reconciles the tree against that on every run by
|
||||
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
|
||||
/// and then installing `playwright` in a second command therefore *deletes the
|
||||
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
|
||||
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
|
||||
/// empty directory is why a fresh setup could report success and still leave
|
||||
/// the pane saying `@playwright/cli` was not installed.
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
spec: &str,
|
||||
specs: &[&str],
|
||||
) -> Result<StepResult, String> {
|
||||
let cmd = vec![
|
||||
let mut cmd = vec![
|
||||
"env".to_string(),
|
||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||
"npm".to_string(),
|
||||
@@ -313,8 +343,8 @@ async fn npm_install(
|
||||
"--no-save".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
"--no-audit".to_string(),
|
||||
spec.to_string(),
|
||||
];
|
||||
cmd.extend(specs.iter().map(|s| s.to_string()));
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
@@ -704,7 +734,7 @@ async fn verify_launch(
|
||||
],
|
||||
vec![
|
||||
format!("TRIPLE_C_PW_DIR={}", dir),
|
||||
format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")),
|
||||
format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
|
||||
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
|
||||
],
|
||||
);
|
||||
@@ -795,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
|
||||
/// The launch check. One `argv` element, no newlines, same contract as the
|
||||
/// detection probe.
|
||||
///
|
||||
/// Playwright leaves the Chromium sandbox disabled by default, which is what
|
||||
/// makes this work in a container at all. The timeout exists so a browser that
|
||||
/// hangs on a missing library still returns a verdict rather than sitting there
|
||||
/// until the exec is torn down. The navigation is best-effort and never decides
|
||||
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken
|
||||
/// install.
|
||||
/// `chromiumSandbox` is set explicitly rather than left to Playwright's
|
||||
/// default, so this check states the same thing the seeded
|
||||
/// `cli.config.json` does instead of agreeing with it by coincidence. The
|
||||
/// containers forbid unprivileged user namespaces, so a sandboxed Chromium
|
||||
/// aborts on launch; nothing here should be able to drift back into testing a
|
||||
/// configuration the viewer will not use.
|
||||
///
|
||||
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
|
||||
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
|
||||
/// names the channel that failed, because "is not installed" is meaningless
|
||||
/// without it. Only the last launch loads a page: the navigation is
|
||||
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
|
||||
/// network apart from a broken install, so doing it once is enough.
|
||||
///
|
||||
/// The timeout exists so a browser that hangs on a missing library still
|
||||
/// returns a verdict rather than sitting there until the exec is torn down.
|
||||
const LAUNCH_PROBE: &str = concat!(
|
||||
r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#,
|
||||
r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
|
||||
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
|
||||
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
|
||||
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
|
||||
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
|
||||
r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#,
|
||||
r#"let v="";try{v=b.version();}catch(e){}"#,
|
||||
r#"let nav={ok:true,cert:false,detail:""};"#,
|
||||
r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
|
||||
r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
|
||||
r#"let v="",nav={ok:true,cert:false,detail:""};"#,
|
||||
r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
|
||||
r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
|
||||
r#"try{v=b.version();}catch(e){}"#,
|
||||
r#"if(i===list.length-1){"#,
|
||||
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
|
||||
// A certificate failure is classified here, next to the message, because
|
||||
// Chromium's wording is the only place the distinction exists.
|
||||
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#,
|
||||
r#"await b.close();clearTimeout(t);say(true,v,nav);}"#,
|
||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#,
|
||||
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
|
||||
r#"await b.close();b=null;}"#,
|
||||
r#"clearTimeout(t);say(true,v,nav);}"#,
|
||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
|
||||
r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
|
||||
r#"process.exit(0);})();"#,
|
||||
);
|
||||
|
||||
@@ -984,8 +1030,10 @@ mod tests {
|
||||
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
||||
// must be able to say so.
|
||||
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
||||
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome"));
|
||||
assert_eq!(BrowserTarget::Chromium.channel(), None);
|
||||
assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
|
||||
// Both of Chromium's consumers, or the check passes for a browser the
|
||||
// viewer cannot open — see `channels`.
|
||||
assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
|
||||
// And a size, before the click, for both.
|
||||
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
||||
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
||||
|
||||
@@ -34,14 +34,22 @@
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
|
||||
//! Off by default and per-project opt-in. The opt-in itself is
|
||||
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
|
||||
//! `auth_bridge_enabled` and read from the store on demand rather than cached
|
||||
//! here — so the pane comes back the way it was left. What does *not* persist
|
||||
//! is the session: nothing starts a viewer on app start, so a project left
|
||||
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
|
||||
//! for one. That is deliberate, and the reason the flag and the session are
|
||||
//! separate ideas — see [`BrowserViewManager::status`].
|
||||
//!
|
||||
//! One supervisor task per session owns the proxy and the viewer process, and it
|
||||
//! is the only thing that tears them down, so every way a session can end funnels
|
||||
//! through one code path:
|
||||
//!
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
@@ -59,7 +67,10 @@
|
||||
//! orphan is reachable on container loopback only: the host-side port dies with
|
||||
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
|
||||
//! precisely so the bridge will not mirror an orphan the next time the app
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it.
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
|
||||
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
|
||||
//! which is exactly the state that invites the user to press the button that
|
||||
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
|
||||
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BrowserViewStatus {
|
||||
/// The per-project opt-in. Off by default.
|
||||
/// The per-project opt-in, read from the persisted project record. Off by
|
||||
/// default, and true without a `Running` state whenever the view is turned
|
||||
/// on but has nothing up — a stopped container, or an app that has just
|
||||
/// restarted and does not auto-start viewers.
|
||||
pub enabled: bool,
|
||||
pub state: BrowserViewState,
|
||||
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
|
||||
@@ -201,17 +215,20 @@ struct Session {
|
||||
|
||||
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
|
||||
|
||||
/// Live sessions, and nothing else.
|
||||
///
|
||||
/// The per-project opt-in deliberately is **not** a field here. It lives on
|
||||
/// the project record as
|
||||
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
|
||||
/// is read from [`ProjectsStore`] at each use, exactly as
|
||||
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
|
||||
/// one copy, durable across a restart, and impossible to get out of step with
|
||||
/// what the Config tab shows. A cached copy here was the previous design and
|
||||
/// its only observable behaviour was forgetting the user's choice on every
|
||||
/// app start.
|
||||
#[derive(Default)]
|
||||
pub struct BrowserViewManager {
|
||||
sessions: SessionMap,
|
||||
/// The per-project opt-in.
|
||||
///
|
||||
/// NOTE: in memory only, so it does not survive an app restart. The durable
|
||||
/// home for this is a `browser_view_enabled: bool` field on
|
||||
/// `models::Project` (see the report) — `models/project.rs` is out of scope
|
||||
/// for this change, so the flag lives here and the wiring is otherwise
|
||||
/// identical to `auth_bridge_enabled`.
|
||||
enabled: Mutex<std::collections::HashSet<String>>,
|
||||
next_epoch: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
|
||||
}
|
||||
|
||||
impl BrowserViewManager {
|
||||
pub async fn is_enabled(&self, project_id: &str) -> bool {
|
||||
self.enabled.lock().await.contains(project_id)
|
||||
}
|
||||
|
||||
async fn set_enabled(&self, project_id: &str, enabled: bool) {
|
||||
let mut set = self.enabled.lock().await;
|
||||
if enabled {
|
||||
set.insert(project_id.to_string());
|
||||
} else {
|
||||
set.remove(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status without touching the container.
|
||||
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
|
||||
let enabled = self.is_enabled(project_id).await;
|
||||
///
|
||||
/// `enabled` is passed in rather than looked up, the way
|
||||
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
|
||||
/// the caller's to read from the store, and keeping it out of here is what
|
||||
/// stops a second copy of it appearing. A project whose view is enabled but
|
||||
/// whose container is stopped — or whose app has just restarted — reports
|
||||
/// `enabled: true` with a state of `Off`, which is the honest answer.
|
||||
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
|
||||
match self.sessions.lock().await.get(project_id) {
|
||||
Some(session) => BrowserViewStatus {
|
||||
enabled,
|
||||
@@ -261,6 +271,14 @@ impl BrowserViewManager {
|
||||
///
|
||||
/// Idempotent: a call while a live session exists returns that session's
|
||||
/// status untouched, so re-opening the tab does not restart the dashboard.
|
||||
///
|
||||
/// This is the single funnel for turning the view **on**, so it is also
|
||||
/// where the durable flag is written — both call sites (the toggle and
|
||||
/// `open_page_in_container_browser`, which opens a page and then shows it)
|
||||
/// mean "on", and neither can forget. The **off** direction is not
|
||||
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
|
||||
/// that are not the user changing their mind, so the command owns that
|
||||
/// write. See [`Self::stop`].
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
@@ -268,7 +286,7 @@ impl BrowserViewManager {
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
self.set_enabled(&project_id, true).await;
|
||||
store.set_browser_view_enabled(&project_id, true)?;
|
||||
|
||||
// Bind the answer before acting on it: `status()` takes the same lock,
|
||||
// and this mutex is not reentrant.
|
||||
@@ -279,7 +297,7 @@ impl BrowserViewManager {
|
||||
.get(&project_id)
|
||||
.is_some_and(|s| !s.supervisor.is_finished());
|
||||
if already_live {
|
||||
return Ok(self.status(&project_id).await);
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
@@ -304,21 +322,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
|
||||
@@ -378,14 +382,21 @@ impl BrowserViewManager {
|
||||
},
|
||||
);
|
||||
|
||||
let status = self.status(&project_id).await;
|
||||
let status = self.status(&project_id, true).await;
|
||||
emit(&app, &project_id, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Stop one project's view and wait until its host port has been released.
|
||||
///
|
||||
/// Tears the *session* down and deliberately leaves the durable flag alone.
|
||||
/// Most callers are not the user turning the feature off — a migration
|
||||
/// removes the container out from under a running view
|
||||
/// (`migration_commands`), and the container can stop for any other reason
|
||||
/// — and persisting `false` for those would quietly opt the project out of
|
||||
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
|
||||
/// the one caller that means it, and it writes the flag itself first.
|
||||
pub async fn stop(&self, project_id: &str) {
|
||||
self.set_enabled(project_id, false).await;
|
||||
// Remove under the lock, then release it before awaiting: the
|
||||
// supervisor takes the same lock to deregister itself on exit.
|
||||
let session = self.sessions.lock().await.remove(project_id);
|
||||
@@ -497,7 +508,12 @@ async fn supervise(
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
let _ = popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
// Straight from the store, like the auth bridge's own teardown emit: the
|
||||
// session is over, but the project may well still be opted in — a stopped
|
||||
// container is not a changed mind, and the pane has to show the difference.
|
||||
let enabled = store
|
||||
.get(&project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled);
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
@@ -601,12 +617,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 +711,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.",
|
||||
@@ -850,6 +945,25 @@ mod tests {
|
||||
assert!(s.url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_opt_in_and_the_live_session_are_separate_answers() {
|
||||
let manager = BrowserViewManager::default();
|
||||
|
||||
// Exactly what the pane reads on mount after an app restart of a
|
||||
// project that was left enabled: the durable flag says on, and nothing
|
||||
// auto-starts, so the state is honestly `Off`. The old in-memory flag
|
||||
// could not express this — it came back `false` and the pane silently
|
||||
// showed the feature as never having been turned on.
|
||||
let status = manager.status("p1", true).await;
|
||||
assert!(status.enabled);
|
||||
assert_eq!(status.state, BrowserViewState::Off);
|
||||
assert!(status.url.is_none());
|
||||
|
||||
// The flag belongs to the caller, read from the store. The manager
|
||||
// keeps no copy, so it has nothing to contradict it with.
|
||||
assert!(!manager.status("p1", false).await.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
||||
let mut d = PlaywrightDetection::default();
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
//! The command census shared by `build.rs` and the `cargo test` suite.
|
||||
//!
|
||||
//! `build.rs` pulls this file in with `#[path = "src/command_census.rs"]` and `lib.rs` with
|
||||
//! `#[cfg(test)] mod command_census;`, so the parser that decides what the Tauri `AppManifest`
|
||||
//! declares is the parser the tests exercise, and the rules that decide whether the build
|
||||
//! passes have unit tests. Nothing here may reference the crate: only `std` and `serde_json`
|
||||
//! (a dependency of both the crate and the build script).
|
||||
//!
|
||||
//! Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` §3.2.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// The command names inside `generate_handler![ … ])` in `lib.rs`, in registration order,
|
||||
/// duplicates kept (the caller decides whether that is an error). `None` if the block is
|
||||
/// missing or unterminated.
|
||||
///
|
||||
/// Comma-split, not line-split: `// Docker` style comments are stripped from every line first
|
||||
/// (a whole-line comment strips to nothing; a trailing one leaves the code before it), and the
|
||||
/// *cleaned* text is then split on `,` so each grant is its own item regardless of how many
|
||||
/// share a line. A line-split version of this parser shipped first and used
|
||||
/// `rsplit("::").next()` once *per line*: two commands on one line (`a::x, b::y,`) collapsed to
|
||||
/// a single item, silently dropping `a::x` — a denied command at runtime with nothing flagging
|
||||
/// it. Comma-splitting fixes that because it no longer assumes one item per line.
|
||||
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>> {
|
||||
let (_, rest) = lib_rs.split_once("generate_handler![")?;
|
||||
let (inside, _) = rest.split_once("])")?;
|
||||
let cleaned: String = inside
|
||||
.lines()
|
||||
// Strip a trailing `//` comment (and a whole-line one, which strips to "").
|
||||
.map(|l| l.split("//").next().unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Some(
|
||||
cleaned
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
// `a::b::name` → `name`; a bare `name` (no `::`) is its own last segment.
|
||||
s.rsplit("::").next().map(|n| n.trim().to_string())
|
||||
})
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `viewer_read_file` → `allow-viewer-read-file`. tauri-utils 2.9.0 (`acl/build.rs:290`)
|
||||
/// replaces only `_`; permission identifiers may not contain `_`, but the command name inside
|
||||
/// the generated permission stays snake_case.
|
||||
pub fn allow_permission(command: &str) -> String {
|
||||
format!("allow-{}", command.replace('_', "-"))
|
||||
}
|
||||
|
||||
/// The `windows` list of the one capability file that may grant `command`. A command that
|
||||
/// must be callable from both windows is a design change: make it here, visibly, rather than
|
||||
/// by widening a capability file.
|
||||
pub fn expected_windows(command: &str) -> &'static [&'static str] {
|
||||
if command.starts_with("viewer_") {
|
||||
&["file-viewer-*"]
|
||||
} else {
|
||||
&["main"]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CapabilityFile {
|
||||
pub name: String,
|
||||
pub windows: Vec<String>,
|
||||
pub bare: Vec<String>,
|
||||
}
|
||||
|
||||
/// One `capabilities/*.json`, reduced to what the census checks. Plugin and core grants
|
||||
/// (anything with a `:`) are not this module's business; the exact-set tests in `lib.rs` and
|
||||
/// `file_viewer/mod.rs` pin those.
|
||||
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?;
|
||||
// `webviews` would extend the grants to webviews by label (the browser-view pop-out is
|
||||
// meant to be in no capability), and `remote` would extend them to a remote origin. The
|
||||
// census reasons about `windows` only, so either key is refused rather than half-checked.
|
||||
for key in ["webviews", "remote"] {
|
||||
if value.get(key).is_some() {
|
||||
return Err(format!(
|
||||
"{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only"
|
||||
));
|
||||
}
|
||||
}
|
||||
let windows = value["windows"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("{name}: `windows` must be an array"))?
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("{name}: `windows` entries must be strings"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut bare = Vec::new();
|
||||
for grant in value["permissions"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("{name}: `permissions` must be an array"))?
|
||||
{
|
||||
let id = match grant {
|
||||
serde_json::Value::String(s) => s.as_str(),
|
||||
serde_json::Value::Object(o) => o
|
||||
.get("identifier")
|
||||
.and_then(|i| i.as_str())
|
||||
.ok_or_else(|| format!("{name}: a scoped grant needs a string `identifier`"))?,
|
||||
_ => return Err(format!("{name}: a grant is a string or an object")),
|
||||
};
|
||||
if !id.contains(':') {
|
||||
bare.push(id.to_string());
|
||||
}
|
||||
}
|
||||
Ok(CapabilityFile { name: name.to_string(), windows, bare })
|
||||
}
|
||||
|
||||
/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or
|
||||
/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with
|
||||
/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the
|
||||
/// census reads only top-level JSON, so anything else tauri might load is refused rather than
|
||||
/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the
|
||||
/// caller's to skip first (see [`is_os_junk`]).
|
||||
pub fn stray_capability_entry(name: &str, is_file: bool) -> Option<String> {
|
||||
if !is_file {
|
||||
return Some(format!(
|
||||
"capabilities/{name} is not a regular file; tauri loads capabilities from \
|
||||
subdirectories too, so every capability must be a top-level capabilities/*.json"
|
||||
));
|
||||
}
|
||||
if name.ends_with(".json") {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \
|
||||
too) but the census cannot check it, so every capability must be a top-level \
|
||||
capabilities/*.json"
|
||||
))
|
||||
}
|
||||
|
||||
/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`,
|
||||
/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from
|
||||
/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs
|
||||
/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it,
|
||||
/// and the caller must refuse it.
|
||||
pub fn is_os_junk(name: &str) -> bool {
|
||||
let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e));
|
||||
!loadable
|
||||
&& (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini")
|
||||
|| name.ends_with(".swp")
|
||||
|| name.ends_with(".swo")
|
||||
|| name.ends_with('~'))
|
||||
}
|
||||
|
||||
/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`,
|
||||
/// `Tauri.toml` and the per-platform `tauri.<platform>.conf.json[5]` / `Tauri.<platform>.toml`
|
||||
/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a
|
||||
/// format it cannot (JSON5/TOML), `None` = not a tauri config file.
|
||||
pub fn tauri_config_file(name: &str) -> Option<bool> {
|
||||
if name.starts_with("tauri.") && name.ends_with(".conf.json") {
|
||||
Some(true)
|
||||
} else if (name.starts_with("tauri.") && name.ends_with(".conf.json5"))
|
||||
|| (name.starts_with("Tauri.") && name.ends_with(".toml"))
|
||||
{
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that
|
||||
/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it
|
||||
/// is non-empty: an inline object is a capability the census never sees, and a list of
|
||||
/// identifiers switches every *other* capability file off, which the census also assumes is
|
||||
/// not happening.
|
||||
pub fn tauri_config_problem(name: &str, json: &str) -> Option<String> {
|
||||
let value: serde_json::Value = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(format!("{name}: not valid JSON: {e}")),
|
||||
};
|
||||
match value.pointer("/app/security/capabilities") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(serde_json::Value::Array(a)) if a.is_empty() => None,
|
||||
Some(_) => Some(format!(
|
||||
"{name}: app.security.capabilities is not allowed; every capability lives in a \
|
||||
top-level capabilities/*.json file, where the census checks it"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that must hold between the handler list and the capability files. Returns every
|
||||
/// violation rather than the first, so a batch of forgotten grants is one build failure; an
|
||||
/// empty vector is a pass.
|
||||
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
if commands.is_empty() {
|
||||
problems.push(
|
||||
"no commands were parsed out of generate_handler! — an empty AppManifest would \
|
||||
silently leave every app command ungated"
|
||||
.to_string(),
|
||||
);
|
||||
return problems;
|
||||
}
|
||||
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::new();
|
||||
for c in commands {
|
||||
if !c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
|
||||
problems.push(format!("{c:?} is not a command name ([a-z0-9_]+)"));
|
||||
}
|
||||
if !seen.insert(c.as_str()) {
|
||||
problems.push(format!("{c} is registered more than once"));
|
||||
}
|
||||
}
|
||||
|
||||
let known: BTreeMap<String, &str> =
|
||||
seen.iter().map(|c| (allow_permission(c), *c)).collect();
|
||||
for f in files {
|
||||
let windows: Vec<&str> = f.windows.iter().map(String::as_str).collect();
|
||||
for id in &f.bare {
|
||||
match known.get(id) {
|
||||
Some(command) => {
|
||||
let want = expected_windows(command);
|
||||
if windows.as_slice() != want {
|
||||
problems.push(format!(
|
||||
"{}: {id} must be granted in the capability file whose windows are \
|
||||
{want:?}, not {windows:?}",
|
||||
f.name
|
||||
));
|
||||
}
|
||||
}
|
||||
None if id.starts_with("deny-") => problems.push(format!(
|
||||
"{}: {id}: deny-* is global in tauri 2.11 — it would deny the command for \
|
||||
every window, not just this one; use allow-lists only",
|
||||
f.name
|
||||
)),
|
||||
None if id.starts_with("allow-") => problems.push(format!(
|
||||
"{}: {id} names no registered command (the identifier is allow-<command> \
|
||||
with every `_` replaced by `-`)",
|
||||
f.name
|
||||
)),
|
||||
None => problems.push(format!(
|
||||
"{}: {id}: only allow-<command> app grants are permitted as bare identifiers",
|
||||
f.name
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c in &seen {
|
||||
let id = allow_permission(c);
|
||||
let holders: Vec<&str> = files
|
||||
.iter()
|
||||
.filter(|f| f.bare.iter().any(|b| b == &id))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
match holders.len() {
|
||||
0 => problems.push(format!(
|
||||
"{c} is registered but no capability file grants {id}; add it to the file \
|
||||
whose windows are {:?}",
|
||||
expected_windows(c)
|
||||
)),
|
||||
1 => {}
|
||||
_ => problems.push(format!(
|
||||
"{id} is granted in more than one capability file: {holders:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cmds(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(|n| n.to_string()).collect()
|
||||
}
|
||||
|
||||
fn file(name: &str, windows: &[&str], bare: &[&str]) -> CapabilityFile {
|
||||
CapabilityFile {
|
||||
name: name.to_string(),
|
||||
windows: windows.iter().map(|w| w.to_string()).collect(),
|
||||
bare: bare.iter().map(|b| b.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two files as they must look after the lockdown, for a three-command app.
|
||||
fn good_files() -> Vec<CapabilityFile> {
|
||||
vec![
|
||||
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
|
||||
]
|
||||
}
|
||||
|
||||
const THREE: &[&str] = &["check_docker", "open_file_viewer", "viewer_read_file"];
|
||||
|
||||
#[test]
|
||||
fn the_parser_reads_the_handler_list_in_order_and_ignores_comments() {
|
||||
let lib_rs = r#"
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Docker
|
||||
commands::docker_commands::check_docker,
|
||||
commands::docker_commands::build_image, // trailing comment is not a command
|
||||
url_open::open_url_external,
|
||||
|
||||
// Viewer
|
||||
commands::file_viewer_commands::viewer_read_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
"#;
|
||||
assert_eq!(
|
||||
registered_commands(lib_rs).unwrap(),
|
||||
cmds(&["check_docker", "build_image", "open_url_external", "viewer_read_file"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parser_keeps_duplicates_so_the_caller_can_report_them() {
|
||||
let lib_rs = "generate_handler![\n a::x,\n b::x,\n])";
|
||||
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "x"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parser_returns_none_without_a_handler_block() {
|
||||
assert_eq!(registered_commands("fn main() {}"), None);
|
||||
assert_eq!(registered_commands("generate_handler![ a::b, "), None, "unterminated");
|
||||
}
|
||||
|
||||
/// The bug this regression-tests: a line-split parser applies `rsplit("::").next()` once
|
||||
/// per *line*, so two commands sharing a line collapse into one item and the first is
|
||||
/// silently dropped. Comma-splitting must keep both regardless of layout.
|
||||
#[test]
|
||||
fn two_commands_on_one_line_are_both_kept() {
|
||||
let lib_rs = "generate_handler![\n a::x, b::y,\n])";
|
||||
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "y"]));
|
||||
}
|
||||
|
||||
/// Mirrors the real `lib.rs` handler list's shape: `// Section` comments between groups,
|
||||
/// and command paths one (`open_url_external`), two (`url_open::open_url_external`) and
|
||||
/// three (`commands::docker_commands::check_docker`) segments deep, all ending in a comma
|
||||
/// except the last entry before `])`.
|
||||
#[test]
|
||||
fn a_fixture_shaped_like_the_real_handler_list_parses_every_command() {
|
||||
let lib_rs = r#"
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Docker
|
||||
commands::docker_commands::check_docker,
|
||||
commands::docker_commands::build_image,
|
||||
// Opening a link in the host browser
|
||||
url_open::open_url_external,
|
||||
// Bare, module-less command
|
||||
open_help,
|
||||
// Terminal file viewer
|
||||
commands::file_viewer_commands::viewer_read_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
"#;
|
||||
assert_eq!(
|
||||
registered_commands(lib_rs).unwrap(),
|
||||
cmds(&[
|
||||
"check_docker",
|
||||
"build_image",
|
||||
"open_url_external",
|
||||
"open_help",
|
||||
"viewer_read_file",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_identifiers_replace_only_underscores() {
|
||||
assert_eq!(allow_permission("check_docker"), "allow-check-docker");
|
||||
assert_eq!(allow_permission("viewer_read_file"), "allow-viewer-read-file");
|
||||
assert_eq!(allow_permission("aws_sso_refresh"), "allow-aws-sso-refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_commands_belong_to_the_viewer_windows_and_nothing_else_does() {
|
||||
assert_eq!(expected_windows("viewer_read_file"), ["file-viewer-*"]);
|
||||
assert_eq!(expected_windows("open_file_viewer"), ["main"]);
|
||||
assert_eq!(expected_windows("check_docker"), ["main"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_file_yields_its_windows_and_bare_grants_only() {
|
||||
let json = r#"{
|
||||
"identifier": "default",
|
||||
"description": "x",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
{ "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/*" }] },
|
||||
"allow-check-docker",
|
||||
{ "identifier": "allow-list-projects" }
|
||||
]
|
||||
}"#;
|
||||
let parsed = capability_file("default.json", json).unwrap();
|
||||
assert_eq!(parsed.name, "default.json");
|
||||
assert_eq!(parsed.windows, vec!["main"]);
|
||||
assert_eq!(parsed.bare, vec!["allow-check-docker", "allow-list-projects"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_file_without_windows_or_permissions_is_an_error() {
|
||||
assert!(capability_file("x.json", r#"{"permissions": []}"#).unwrap_err().contains("windows"));
|
||||
assert!(capability_file("x.json", r#"{"windows": ["main"]}"#).unwrap_err().contains("permissions"));
|
||||
assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webviews_and_remote_keys_are_refused() {
|
||||
let with = |extra: &str| {
|
||||
format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#)
|
||||
};
|
||||
let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err();
|
||||
assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}");
|
||||
let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err();
|
||||
assert!(err.contains("`remote`"), "{err}");
|
||||
// Present-but-empty is still refused: the key itself is the widening surface.
|
||||
assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_top_level_json_files_are_capabilities() {
|
||||
assert_eq!(stray_capability_entry("default.json", true), None);
|
||||
for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] {
|
||||
let err = stray_capability_entry(name, true).expect(name);
|
||||
assert!(err.contains(name) && err.contains("not a .json file"), "{err}");
|
||||
}
|
||||
let err = stray_capability_entry("sub", false).unwrap();
|
||||
assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}");
|
||||
// A directory named like a capability is still a directory.
|
||||
assert!(stray_capability_entry("x.json", false).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_junk_is_recognised_but_never_something_tauri_would_load() {
|
||||
for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] {
|
||||
assert!(is_os_junk(junk), "{junk}");
|
||||
}
|
||||
for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] {
|
||||
assert!(!is_os_junk(real), "{real}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_config_files_are_found_by_name_and_format() {
|
||||
assert_eq!(tauri_config_file("tauri.conf.json"), Some(true));
|
||||
assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true));
|
||||
assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false));
|
||||
assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false));
|
||||
assert_eq!(tauri_config_file("Tauri.toml"), Some(false));
|
||||
assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false));
|
||||
assert_eq!(tauri_config_file("Cargo.toml"), None);
|
||||
assert_eq!(tauri_config_file("build.rs"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_capabilities_in_the_tauri_config_are_refused() {
|
||||
let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#;
|
||||
assert_eq!(tauri_config_problem("tauri.conf.json", ok), None);
|
||||
assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None);
|
||||
assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None);
|
||||
let inline = r#"{"app": {"security": {"capabilities": [
|
||||
{"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]}
|
||||
]}}}"#;
|
||||
let err = tauri_config_problem("tauri.conf.json", inline).unwrap();
|
||||
assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}");
|
||||
let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#;
|
||||
assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG"));
|
||||
assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correct_census_has_no_problems() {
|
||||
assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_command_list_is_refused_because_it_would_disable_the_acl() {
|
||||
let problems = check(&[], &good_files());
|
||||
assert_eq!(problems.len(), 1);
|
||||
assert!(problems[0].contains("no commands"), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_without_a_grant_is_named_together_with_the_file_it_belongs_in() {
|
||||
let files = vec![
|
||||
file("default.json", &["main"], &["allow-check-docker"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
|
||||
];
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("open_file_viewer"));
|
||||
assert!(problems[0].contains("allow-open-file-viewer"));
|
||||
assert!(problems[0].contains("[\"main\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_in_two_files_is_reported_once_naming_both() {
|
||||
let files = vec![
|
||||
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
|
||||
file("extra.json", &["main"], &["allow-check-docker"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
|
||||
];
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("allow-check-docker"));
|
||||
assert!(problems[0].contains("default.json") && problems[0].contains("extra.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_that_names_no_command_is_a_typo() {
|
||||
let mut files = good_files();
|
||||
files[0].bare.push("allow-check-dokcer".to_string());
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("default.json: allow-check-dokcer"));
|
||||
assert!(problems[0].contains("no registered command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_grants_are_refused_with_the_reason() {
|
||||
let mut files = good_files();
|
||||
files[1].bare.push("deny-check-docker".to_string());
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("file-viewer.json: deny-check-docker"));
|
||||
assert!(problems[0].contains("global"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_bare_identifiers_are_refused() {
|
||||
let mut files = good_files();
|
||||
files[0].bare.push("default".to_string());
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("default.json: default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_in_the_wrong_file_is_refused_even_though_it_is_granted_exactly_once() {
|
||||
let files = vec![
|
||||
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer", "allow-viewer-read-file"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &[]),
|
||||
];
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("allow-viewer-read-file"));
|
||||
assert!(problems[0].contains("[\"file-viewer-*\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widened_windows_list_is_the_wrong_file_too() {
|
||||
let files = vec![
|
||||
file("default.json", &["main", "file-viewer-*"], &["allow-check-docker", "allow-open-file-viewer"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
|
||||
];
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
assert_eq!(problems.len(), 2, "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_names_and_duplicate_registrations_are_refused() {
|
||||
let commands = cmds(&["check_docker", "Check-Docker", "check_docker", "open_file_viewer", "viewer_read_file"]);
|
||||
let problems = check(&commands, &good_files());
|
||||
assert!(problems.iter().any(|p| p.contains("\"Check-Docker\"") && p.contains("[a-z0-9_]+")), "{problems:?}");
|
||||
assert!(problems.iter().any(|p| p.contains("check_docker is registered more than once")), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_problem_is_reported_in_one_pass() {
|
||||
let files = vec![
|
||||
file("default.json", &["main"], &["allow-check-docker", "allow-nope", "deny-check-docker"]),
|
||||
file("file-viewer.json", &["file-viewer-*"], &[]),
|
||||
];
|
||||
let problems = check(&cmds(THREE), &files);
|
||||
// typo, deny, open_file_viewer missing, viewer_read_file missing
|
||||
assert_eq!(problems.len(), 4, "{problems:?}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! IPC for the terminal file viewer. Every command here is gated on the calling
|
||||
//! window's label and reads its target from the registry — no path, no label, no
|
||||
//! project id crosses IPC from a viewer window. See spec §6.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::commands::file_commands::{
|
||||
fetch_container_file, not_running_message, require_running, validate_container_write_path, MAX_READ_BYTES,
|
||||
};
|
||||
use crate::file_viewer::is_viewer_label;
|
||||
use crate::file_viewer::poll::{poll_file, ViewerPoll};
|
||||
use crate::file_viewer::registry::{
|
||||
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
|
||||
};
|
||||
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::window::open_viewer_window;
|
||||
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
|
||||
use crate::models::Project;
|
||||
use crate::AppState;
|
||||
|
||||
pub const GOTO_EVENT: &str = "file-viewer-goto";
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ViewerState {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub raw_path: String,
|
||||
pub state: ViewerTargetState,
|
||||
pub initial: Location,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ViewerFile {
|
||||
pub contents_base64: String,
|
||||
pub truncated: bool,
|
||||
pub size: u64,
|
||||
pub hash: String,
|
||||
pub editable: bool,
|
||||
pub readonly_reason: Option<String>,
|
||||
}
|
||||
|
||||
fn require_main(window_label: &str) -> Result<(), String> {
|
||||
if window_label == "main" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Only the main window can open files.".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_viewer(window_label: &str) -> Result<String, String> {
|
||||
if is_viewer_label(window_label) {
|
||||
Ok(window_label.to_string())
|
||||
} else {
|
||||
Err("This command belongs to a file window.".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState {
|
||||
ViewerState {
|
||||
project_id: target.project_id,
|
||||
project_name: target.project_name,
|
||||
raw_path: target.raw_path,
|
||||
state: target.state,
|
||||
initial: target.initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_title(raw_path: &str, project_name: &str) -> String {
|
||||
let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path);
|
||||
format!("{} — {}", base, project_name)
|
||||
}
|
||||
|
||||
/// Refuses a save payload before decoding it: base64 of at most
|
||||
/// [`MAX_WRITE_BYTES`] is at most `4 * ceil(MAX_WRITE_BYTES / 3)` characters.
|
||||
/// `write_file` enforces the cap on the decoded bytes too; this stops a
|
||||
/// compromised viewer from making the app allocate and decode an arbitrarily
|
||||
/// large string first.
|
||||
fn check_encoded_len(encoded_len: usize) -> Result<(), String> {
|
||||
if encoded_len > MAX_WRITE_BYTES.div_ceil(3) * 4 {
|
||||
return Err("Files over 1 MiB are read-only in the viewer.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The caller's registry entry, or a sentence.
|
||||
fn own_target(
|
||||
window: &tauri::Window,
|
||||
registry: &ViewerRegistry,
|
||||
) -> Result<(String, ViewerTarget), String> {
|
||||
let label = require_viewer(window.label())?;
|
||||
let target = registry
|
||||
.get(&label)
|
||||
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
|
||||
Ok((label, target))
|
||||
}
|
||||
|
||||
fn resolved_path(target: &ViewerTarget) -> Result<String, String> {
|
||||
match &target.state {
|
||||
ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()),
|
||||
_ => Err("Choose a file first.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place a viewer command looks up its project (P14).
|
||||
fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
|
||||
state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| "This project no longer exists.".to_string())
|
||||
}
|
||||
|
||||
/// `action` completes "Start the project before …", e.g. "saving this file".
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
|
||||
let container_id = project
|
||||
.container_id
|
||||
.clone()
|
||||
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
|
||||
require_running(&container_id, action).await?;
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
/// The container of the project a viewer window belongs to, if it is running.
|
||||
async fn running_container_for(
|
||||
state: &AppState,
|
||||
target: &ViewerTarget,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
running_container_of(&project_of(state, &target.project_id)?, action).await
|
||||
}
|
||||
|
||||
/// Raises an existing viewer window and moves it to `location`.
|
||||
fn focus_viewer(app: &AppHandle, label: &str, location: Location) {
|
||||
if let Some(existing) = app.get_webview_window(label) {
|
||||
let _ = existing.unminimize();
|
||||
let _ = existing.set_focus();
|
||||
let _ = app.emit_to(label, GOTO_EVENT, location);
|
||||
}
|
||||
}
|
||||
|
||||
// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[tauri::command]
|
||||
pub async fn open_file_viewer(
|
||||
project_id: String,
|
||||
path: String,
|
||||
line: Option<u32>,
|
||||
col: Option<u32>,
|
||||
end_line: Option<u32>,
|
||||
window: tauri::Window,
|
||||
app: AppHandle,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
require_main(window.label())?;
|
||||
let project = project_of(&state, &project_id)?;
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
|
||||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||||
let candidates = candidate_paths(&path, &mounts)?;
|
||||
let matches = probe_candidates(&container_id, &candidates).await?;
|
||||
let initial = Location { line, col, end_line };
|
||||
|
||||
let target_state = match matches.len() {
|
||||
0 => ViewerTargetState::NotFound { tried: candidates },
|
||||
1 => ViewerTargetState::Resolved { container_path: matches[0].clone() },
|
||||
_ => ViewerTargetState::Choose { candidates: matches },
|
||||
};
|
||||
|
||||
let title = window_title(&path, &project.name);
|
||||
let target = ViewerTarget {
|
||||
project_id,
|
||||
project_name: project.name.clone(),
|
||||
raw_path: path,
|
||||
state: target_state,
|
||||
initial: initial.clone(),
|
||||
};
|
||||
// Dedupe, stale pruning and the cap are one registry call, so a second click
|
||||
// while the first window is still being built finds it rather than reading
|
||||
// its not-yet-existing window as stale.
|
||||
let label = match registry.reserve(target, |l| app.get_webview_window(l).is_some())? {
|
||||
Reservation::Reserved(label) => label,
|
||||
// Still being built: it opens at its own location in a moment.
|
||||
Reservation::Existing { built: false, .. } => return Ok(()),
|
||||
Reservation::Existing { label, built: true } => {
|
||||
focus_viewer(&app, &label, initial);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(e) = open_viewer_window(&app, &label, &title) {
|
||||
registry.remove(&label);
|
||||
return Err(e);
|
||||
}
|
||||
registry.mark_built(&label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn viewer_get_state(
|
||||
window: tauri::Window,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
) -> Result<ViewerState, String> {
|
||||
let (label, target) = own_target(&window, ®istry)?;
|
||||
Ok(viewer_state_of(&label, target))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn viewer_choose_file(
|
||||
index: usize,
|
||||
window: tauri::Window,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
) -> Result<ViewerState, String> {
|
||||
let (label, target) = own_target(&window, ®istry)?;
|
||||
let chosen = match &target.state {
|
||||
ViewerTargetState::Choose { candidates } => candidates
|
||||
.get(index)
|
||||
.cloned()
|
||||
.ok_or_else(|| "That choice is no longer available.".to_string())?,
|
||||
_ => return Err("This window is not choosing a file.".into()),
|
||||
};
|
||||
let app = window.app_handle();
|
||||
match registry.choose(&label, chosen, |l| app.get_webview_window(l).is_some())? {
|
||||
Choice::Resolved(updated) => Ok(viewer_state_of(&label, updated)),
|
||||
// Another window already has this file. This window was only ever a
|
||||
// chooser, so hand over to that one and close this one, as a second
|
||||
// click on the same path would have. The error is what this window
|
||||
// shows if the destroy fails.
|
||||
Choice::AlreadyOpen { label: other, .. } => {
|
||||
focus_viewer(app, &other, target.initial);
|
||||
let _ = window.destroy();
|
||||
Err("This file is already open in another window.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn viewer_read_file(
|
||||
max_bytes: u64,
|
||||
window: tauri::Window,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ViewerFile, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "opening files").await?;
|
||||
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(&container_id, &path, cap).await?;
|
||||
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
|
||||
Ok(()) => (true, None),
|
||||
Err(reason) => (false, Some(reason)),
|
||||
};
|
||||
Ok(ViewerFile {
|
||||
hash: sha256_hex(&fetched.bytes),
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
truncated: fetched.truncated,
|
||||
size: fetched.size,
|
||||
editable,
|
||||
readonly_reason,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn viewer_poll_file(
|
||||
window: tauri::Window,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container_id, &path).await
|
||||
}
|
||||
|
||||
/// Errors from `write_file` pass through unchanged: the frontend matches the
|
||||
/// `write::CONFLICT_PREFIX`/`GONE_PREFIX` prefixes and `READ_ONLY_MESSAGE` (TS copies in
|
||||
/// `app/src/viewer/ipcMessages.ts`), and anything else (a full disk) is already a
|
||||
/// sentence it shows as is. Success is a `SavedFile`: the new base hash and the hash
|
||||
/// the disk held right after the swap.
|
||||
#[tauri::command]
|
||||
pub async fn viewer_write_file(
|
||||
contents_base64: String,
|
||||
base_hash: String,
|
||||
window: tauri::Window,
|
||||
registry: State<'_, ViewerRegistry>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SavedFile, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
validate_container_write_path("File", &path)?;
|
||||
check_encoded_len(contents_base64.len())?;
|
||||
let bytes = BASE64
|
||||
.decode(contents_base64.as_bytes())
|
||||
.map_err(|_| "The editor sent malformed content.".to_string())?;
|
||||
let container_id = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_is_main_only_and_viewer_commands_are_viewer_only() {
|
||||
assert!(require_main("main").is_ok());
|
||||
assert!(require_main("file-viewer-1").is_err());
|
||||
assert!(require_main("browser-view-x").is_err());
|
||||
assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7");
|
||||
assert!(require_viewer("main").is_err());
|
||||
assert!(require_viewer("file-viewer-").is_err());
|
||||
}
|
||||
|
||||
/// Both "no container" refusals a viewer command can give start with the prefix
|
||||
/// the viewer reads as "Container not running" (`ipcMessages.ts`).
|
||||
#[test]
|
||||
fn not_running_refusals_carry_the_shared_prefix() {
|
||||
use crate::commands::file_commands::NOT_RUNNING_PREFIX;
|
||||
let m = not_running_message("checking this file for changes", "files live in its container");
|
||||
assert_eq!(m, "Start the project before checking this file for changes — files live in its container.");
|
||||
assert!(m.starts_with(NOT_RUNNING_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_file_serialises_both_hashes() {
|
||||
let json = serde_json::to_value(SavedFile { hash: "a".into(), disk_hash: "b".into() }).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "hash": "a", "disk_hash": "b" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_title_is_basename_then_project() {
|
||||
assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C");
|
||||
assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x");
|
||||
assert_eq!(window_title("Makefile", "p"), "Makefile — p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_state_serialises_the_ipc_shape() {
|
||||
let target = ViewerTarget {
|
||||
project_id: "pid".into(),
|
||||
project_name: "P".into(),
|
||||
raw_path: "src/a.rs".into(),
|
||||
state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() },
|
||||
initial: Location { line: Some(3), col: Some(2), end_line: None },
|
||||
};
|
||||
let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap();
|
||||
assert_eq!(json["project_id"], "pid");
|
||||
assert_eq!(json["state"]["kind"], "resolved");
|
||||
assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs");
|
||||
assert_eq!(json["initial"]["line"], 3);
|
||||
assert!(json["initial"]["end_line"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encoded_length_is_capped_before_decoding() {
|
||||
let at_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES]);
|
||||
assert!(check_encoded_len(at_cap.len()).is_ok());
|
||||
// MAX + 1 and MAX + 2 bytes pad to the same length as MAX; `write_file`'s
|
||||
// decoded check refuses those. The first size this bound itself refuses:
|
||||
let over_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES + 3]);
|
||||
assert!(check_encoded_len(over_cap.len()).is_err());
|
||||
assert!(check_encoded_len(at_cap.len() + 1).is_err());
|
||||
assert!(check_encoded_len(0).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
|
||||
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||
/// expressions are not evaluated here.
|
||||
pub next_run: Option<String>,
|
||||
/// Whether a run is in flight right now, from the runner's state file in
|
||||
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
|
||||
pub running: bool,
|
||||
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
|
||||
pub running_since: Option<String>,
|
||||
}
|
||||
|
||||
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
||||
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
|
||||
set -u
|
||||
TASKS="$HOME/.claude/scheduler/tasks"
|
||||
LOGS="$HOME/.claude/scheduler/logs"
|
||||
RUNNING="$HOME/.claude/scheduler/running"
|
||||
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
||||
for f in "$TASKS"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
id=$(jq -r '.id // ""' "$f") || continue
|
||||
[ -n "$id" ] || id=$(basename "$f" .json)
|
||||
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{
|
||||
# Live-run state. The pid is checked, not trusted: a container stopped
|
||||
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
|
||||
# "running" forever is a worse lie than showing nothing.
|
||||
started=""
|
||||
state="$RUNNING/$id.json"
|
||||
if [ -f "$state" ]; then
|
||||
pid=$(jq -r '.pid // empty' "$state")
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
started=$(jq -r '.started_epoch // empty' "$state")
|
||||
fi
|
||||
fi
|
||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
|
||||
id: (if (.id // "") == "" then $fallback_id else .id end),
|
||||
name: (.name // ""),
|
||||
prompt: (.prompt // ""),
|
||||
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
|
||||
enabled: (.enabled == true),
|
||||
working_dir: (.working_dir // "/workspace"),
|
||||
created_at: (.created_at // null),
|
||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end)
|
||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
|
||||
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
|
||||
}' "$f"
|
||||
done | jq -s 'sort_by(.name, .id)'
|
||||
"#;
|
||||
@@ -673,6 +691,7 @@ struct RawScheduledTask {
|
||||
working_dir: String,
|
||||
created_at: Option<String>,
|
||||
last_run_epoch: Option<i64>,
|
||||
running_since_epoch: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
|
||||
created_at: t.created_at,
|
||||
last_run: t.last_run_epoch.map(epoch_to_iso),
|
||||
next_run,
|
||||
running: t.running_since_epoch.is_some(),
|
||||
running_since: t.running_since_epoch.map(epoch_to_iso),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
@@ -1179,9 +1200,14 @@ pub async fn add_scheduled_task(
|
||||
/// * **In that order**, so a rejected `add` leaves the original untouched
|
||||
/// rather than deleting a prompt the user cannot get back. The cost is a
|
||||
/// sub-second window in which both tasks are in the crontab.
|
||||
/// * The task therefore gets a **new id**. Its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
||||
/// UI warns about this before saving.
|
||||
/// * The task therefore gets a **new id**, and its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||
/// scheduler reaps a task's logs when the task stops existing, because
|
||||
/// nothing can name that id again afterwards. The UI warns before saving.
|
||||
/// (A project still running an older base image carries the older
|
||||
/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
|
||||
/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
|
||||
/// copy is deliberately written for the case that loses data.)
|
||||
/// * `enabled` is carried over explicitly, because `add` always creates an
|
||||
/// enabled task and silently re-enabling a task the user had switched off
|
||||
/// would schedule a run they did not ask for.
|
||||
|
||||
@@ -3,13 +3,16 @@ pub mod auth_token_commands;
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod file_viewer_commands;
|
||||
pub mod gateway_commands;
|
||||
pub mod help_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod notes_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod settings_export_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::models::Note;
|
||||
use crate::storage::notes_store;
|
||||
|
||||
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||
/// recently edited.
|
||||
///
|
||||
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||
/// of the same list — cannot drift into two different orders.
|
||||
#[tauri::command]
|
||||
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||
let mut notes = notes_store::load(&project_id)?;
|
||||
notes.sort_by(|a, b| {
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||
});
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
/// Insert or replace one note.
|
||||
///
|
||||
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||
/// here is editing one note.
|
||||
#[tauri::command]
|
||||
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||
notes_store::upsert(&project_id, note)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||
notes_store::delete(&project_id, ¬e_id)
|
||||
}
|
||||
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
||||
Ok(state.settings_store.get())
|
||||
}
|
||||
|
||||
/// Everything `update_settings` refuses a save over, run against the store's
|
||||
/// *current* value and the incoming one.
|
||||
///
|
||||
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||
/// settings save — `settings_export_commands::apply_settings_import`
|
||||
/// restores three keychain secrets in the same command — can run this
|
||||
/// *first* and bail before touching anything, rather than discovering the
|
||||
/// rejection only when `update_settings` itself runs partway through.
|
||||
pub fn validate_settings_update(
|
||||
before: &AppSettings,
|
||||
incoming: &AppSettings,
|
||||
) -> Result<(), String> {
|
||||
// The global half of the same rule the project half gets in
|
||||
// `update_project`: a global custom env var is merged into every project's
|
||||
// container environment, so an unchecked name here reaches all of them.
|
||||
crate::models::validate_env_vars_update(
|
||||
&before.global_custom_env_vars,
|
||||
&incoming.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
// validated its per-project overrides and this side validated nothing,
|
||||
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||
// fallback for **every** project without an override
|
||||
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||
// home volume this release exists to bound.
|
||||
//
|
||||
// Grandfathered the same way project paths are: a value carried over
|
||||
// unchanged still saves, so a store written before this check cannot lock
|
||||
// the user out of their own settings.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
incoming.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
incoming.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
// Third host path this struct owns, same reasoning: any project with
|
||||
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||
// here is a read-write bind mount of whatever it names into every such
|
||||
// project's container.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"Docker socket path",
|
||||
before.docker_socket_path.as_deref(),
|
||||
incoming.docker_socket_path.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
@@ -90,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
GatewayAction::StopIfRunning => {
|
||||
log::info!("Model gateway disabled in settings — stopping the container");
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
||||
log::error!(
|
||||
"Failed to stop the model gateway after it was disabled: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
GatewayAction::RestartIfRunning => {
|
||||
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_image(
|
||||
image_name: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
docker::pull_image(&image_name, move |msg| {
|
||||
let _ = app_handle.emit("image-pull-progress", msg);
|
||||
@@ -302,7 +362,10 @@ mod tests {
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.enabled = false;
|
||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
||||
assert_eq!(
|
||||
gateway_action(&before, &after),
|
||||
GatewayAction::StopIfRunning
|
||||
);
|
||||
// Still true when it was already off — a stray running container is
|
||||
// still a container that shouldn't be up.
|
||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||
//! out of scope: per-project settings, per-project secrets, and anything
|
||||
//! living in a project's Docker volumes.
|
||||
//!
|
||||
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||
//! host path string is the exact shape of bug that produced this app's past
|
||||
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||
//! settings_import` resolves the chosen path itself and remembers it
|
||||
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||
//! the same file without the path ever crossing back over IPC.
|
||||
//!
|
||||
//! The *decrypted payload* is not cached between preview and apply — the
|
||||
//! password the frontend passes to each call is what it already held for
|
||||
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||
//! keeps the plaintext itself — export/import secrets included — around for
|
||||
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||
//!
|
||||
//! **This is new attack surface**: a settings export is a file one person
|
||||
//! can hand another and ask them to import, together with a password, and
|
||||
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||
//! wholesale — see the module doc on `models::settings_export` for the
|
||||
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||
//! and treat that as the standing example of the class of thing to keep
|
||||
//! checking for here, not a one-off fixed bug.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{secure, settings_crypto};
|
||||
use crate::AppState;
|
||||
|
||||
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||
/// whether the file it's about to re-read is the same one the user actually
|
||||
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||
/// on what gets applied — without this, a file replaced on disk between the
|
||||
/// two calls (this app's own stated threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||
/// apply silently different content than what the confirmation dialog showed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingSettingsImport {
|
||||
path: PathBuf,
|
||||
ciphertext_hash: [u8; 32],
|
||||
}
|
||||
|
||||
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(data).into()
|
||||
}
|
||||
|
||||
const FILE_EXTENSION: &str = "triplec";
|
||||
|
||||
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||
/// attacker who can just try a three-character password directly.
|
||||
const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
||||
fn suggested_export_name() -> String {
|
||||
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||
// earlier file just because the save dialog defaults to the same name.
|
||||
format!(
|
||||
"triple-c-settings-{}.{}",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
FILE_EXTENSION
|
||||
)
|
||||
}
|
||||
|
||||
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Export Triple-C settings")
|
||||
.set_file_name(suggested)
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.save_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Import Triple-C settings")
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.pick_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||
/// export with the web-terminal token blanked out of it — see the module
|
||||
/// doc comment on `models::settings_export` for why that field cannot
|
||||
/// travel through `settings` like the rest of this struct.
|
||||
///
|
||||
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||
/// treated as "nothing to export" for that one entry rather than aborting
|
||||
/// the whole export, matching how the rest of this app degrades a keychain
|
||||
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||
/// rather than surfacing it as a hard failure.
|
||||
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||
let mut settings = current;
|
||||
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||
|
||||
let secrets = ExportedSecrets {
|
||||
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||
web_terminal_access_token,
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
/// rather than a "failed" toast for a plain cancel.
|
||||
#[tauri::command]
|
||||
pub async fn export_settings(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||
// close as this pair of languages allows to the frontend's `.length`
|
||||
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||
// characters, which no reasonable password touches.
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(format!(
|
||||
"Use a password of at least {} characters.",
|
||||
MIN_PASSWORD_LEN
|
||||
));
|
||||
}
|
||||
|
||||
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||
if secrets.is_empty() {
|
||||
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||
}
|
||||
|
||||
let payload = SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
};
|
||||
|
||||
let plaintext = Zeroizing::new(
|
||||
serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||
);
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||
|
||||
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||
/// preview (counts and presence flags only — never a secret value) for a
|
||||
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||
///
|
||||
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||
/// against — does **not** remember the decrypted payload itself, so the
|
||||
/// password must be supplied again to actually apply it — seeing the preview
|
||||
/// is not the same as committing to it. The hash exists so it also can't be
|
||||
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||
/// proceed if the file on disk no longer matches what was just previewed.
|
||||
#[tauri::command]
|
||||
pub async fn preview_settings_import(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<SettingsImportPreview>, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to open a settings export.".to_string());
|
||||
}
|
||||
|
||||
let Some(path) = pick_import_open_path(&window).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||
});
|
||||
|
||||
Ok(Some(preview))
|
||||
}
|
||||
|
||||
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||
/// required, not just encouraged, since it is the only place a user is told
|
||||
/// what an import is about to touch before it touches it. That requirement
|
||||
/// is only real if the file can't change out from under it, so this also
|
||||
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||
/// two calls (this feature's own threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) must not be able
|
||||
/// to apply silently different content than what the confirmation dialog
|
||||
/// showed.
|
||||
///
|
||||
/// Global settings are replaced wholesale — an import is "restore this
|
||||
/// environment," not a field-by-field merge. Global secrets are handled
|
||||
/// differently and on purpose: **only secrets actually present in the
|
||||
/// import are written**; a secret the export doesn't have is left alone on
|
||||
/// this machine rather than cleared, because an absent secret in the export
|
||||
/// means "the source machine never had this configured," not "delete this
|
||||
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||
/// for that (signing out of shared auth, clearing the gateway key).
|
||||
///
|
||||
/// Order matters here, twice over.
|
||||
///
|
||||
/// First: the imported settings are **validated before any secret is
|
||||
/// written**, using the same checks `update_settings` itself runs
|
||||
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||
/// host path used to be caught only when `update_settings` ran, by which
|
||||
/// point the three keychain secrets below were already overwritten with the
|
||||
/// file's, each with a fresh rotation id, silently flagging every project
|
||||
/// container for recreation — while the error the user saw talked only
|
||||
/// about the rejected setting and said nothing about the credentials that
|
||||
/// had already moved. Failing this check first makes a rejected import
|
||||
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||
/// mean.
|
||||
///
|
||||
/// Second, among the things that *do* get written: secrets are restored
|
||||
/// **before** the settings replace runs (which is what triggers
|
||||
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||
/// the final key material rather than racing it — restoring the other way
|
||||
/// round left a real window where the running gateway and the keychain
|
||||
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||
/// nudges a running gateway container to recreate itself whenever a secret
|
||||
/// this import carried was actually written — otherwise the running
|
||||
/// container keeps serving the old key material indefinitely while every
|
||||
/// project container is handed the new one.
|
||||
///
|
||||
/// A keychain write failing is reported back rather than only logged: an
|
||||
/// import that silently restores two of three secrets but not the third
|
||||
/// must not read as unqualified success.
|
||||
///
|
||||
/// The pending import is only cleared on success. A failure here (rejected
|
||||
/// by the validation above, a stale-file mismatch, or some other error)
|
||||
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||
/// making them pick the file and re-enter the password again — the
|
||||
/// preview's job was confirming *what* to import, not spending the one
|
||||
/// attempt at applying it.
|
||||
#[tauri::command]
|
||||
pub async fn apply_settings_import(
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SettingsImportOutcome, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to import settings.".to_string());
|
||||
}
|
||||
|
||||
let pending = state
|
||||
.pending_settings_import
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||
|
||||
let encrypted = std::fs::read(&pending.path)
|
||||
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||
return Err(
|
||||
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
|
||||
let current = state.settings_store.get();
|
||||
|
||||
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||
// the keychain, so "leave an absent secret alone" has to be done by
|
||||
// hand here: carry the destination's current token forward when the
|
||||
// import doesn't have one, instead of letting the wholesale replace
|
||||
// below blank it (every export writes `None` there — see
|
||||
// `split_settings_and_secrets`).
|
||||
let mut settings = payload.settings;
|
||||
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||
.or_else(|| current.web_terminal.access_token.clone());
|
||||
|
||||
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the shared Claude login: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||
match secure::store_gateway_api_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway provider API key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings.push(format!(
|
||||
"Could not restore the gateway provider API key: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||
match secure::store_gateway_master_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway master key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore the gateway master key: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
|
||||
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||
// *shape* — port, provider, base URL, models — because that's what's
|
||||
// rendered into the container's config. A secret changing with the shape
|
||||
// held constant is invisible to it, so a running gateway container would
|
||||
// otherwise keep serving the old key material forever after an import
|
||||
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||
// (which does include the secret rotation id) means the *next* unrelated
|
||||
// settings save would suddenly and confusingly recreate it instead.
|
||||
if gateway_secret_changed && saved.gateway.enabled {
|
||||
match crate::docker::gateway::gateway_container_presence().await {
|
||||
Ok((true, true)) => {
|
||||
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||
{
|
||||
log::error!(
|
||||
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||
}
|
||||
}
|
||||
|
||||
state.pending_settings_import.lock().await.take();
|
||||
|
||||
Ok(SettingsImportOutcome {
|
||||
settings: saved,
|
||||
secret_restore_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||
/// of the payload is even worth attempting to parse.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FormatVersionProbe {
|
||||
format_version: u32,
|
||||
}
|
||||
|
||||
/// Read and decrypt an export file at `path`, then parse it — see
|
||||
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||
/// exists now for tests that don't need that.
|
||||
#[cfg(test)]
|
||||
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let encrypted =
|
||||
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
read_and_decrypt_bytes(&encrypted, password)
|
||||
}
|
||||
|
||||
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||
/// format version **before** attempting to deserialize the full payload.
|
||||
///
|
||||
/// That ordering is not just tidiness: a version bump that isn't
|
||||
/// deserialize-compatible (a field's type changes, not just a new
|
||||
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||
/// before the version check ever ran, surfacing a raw parse error instead
|
||||
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||
/// errors quote the offending value inline. This file is not attacker
|
||||
/// content in the usual sense (it must still decrypt under the right
|
||||
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||
/// so neither error path below ever interpolates what `serde_json`
|
||||
/// actually says — only a fixed, generic message.
|
||||
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||
|
||||
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||
return Err(format!(
|
||||
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||
Update Triple-C before importing it.",
|
||||
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_blank_treats_whitespace_only_as_absent() {
|
||||
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||
assert_eq!(non_blank(Some("".to_string())), None);
|
||||
assert_eq!(non_blank(None), None);
|
||||
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||
// What `apply_settings_import` compares against the pinned hash from
|
||||
// `preview_settings_import` to detect a file swapped out from under a
|
||||
// pending import — this only defends anything if identical bytes
|
||||
// always hash identically and any change to those bytes changes the
|
||||
// hash.
|
||||
let bytes = b"pretend this is an encrypted export file";
|
||||
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||
|
||||
let mut tampered = bytes.to_vec();
|
||||
tampered[0] ^= 0xFF;
|
||||
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||
}
|
||||
|
||||
fn write_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
payload: &SettingsExportPayload,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||
}
|
||||
|
||||
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||
/// deliberately not shape-compatible, which the typed helper above can't
|
||||
/// produce at all.
|
||||
fn write_raw_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
let plaintext = serde_json::to_vec(value).unwrap();
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, &encrypted).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(
|
||||
secrets.web_terminal_access_token,
|
||||
Some("super-secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(secrets.web_terminal_access_token, None);
|
||||
}
|
||||
|
||||
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||
SettingsExportPayload {
|
||||
format_version,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings: AppSettings::default(),
|
||||
secrets: ExportedSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-settings-export-test-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||
// future version could easily have changed `settings` from an object
|
||||
// to something else) as well as newer — so this only passes under
|
||||
// the probe-first ordering. Parsing the full struct first (the old
|
||||
// behavior) would fail on the shape mismatch and never reach the
|
||||
// version check, producing the "unexpected shape" message instead of
|
||||
// "newer version" / "Update Triple-C".
|
||||
let dir = temp_dir("newer-format");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "9.9.9",
|
||||
"settings": "this-app-version-stores-settings-differently",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||
assert!(err.contains("Update Triple-C"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_at_the_current_format_is_accepted() {
|
||||
let dir = temp_dir("current-format");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||
// A `format_version` the probe accepts, but a `settings` field of
|
||||
// the wrong *type* rather than just a missing field — this is what
|
||||
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||
// struct AppSettings" error that quotes the offending value
|
||||
// verbatim. That value here stands in for plaintext that, in a real
|
||||
// export, could be a live credential — the assertion below is only
|
||||
// meaningful against a fixture that actually exercises serde's
|
||||
// value-quoting behavior, which a merely-missing-field fixture does
|
||||
// not.
|
||||
let dir = temp_dir("malformed");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "0.4.14",
|
||||
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(
|
||||
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||
"leaked plaintext into the error: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("doesn't look like a valid settings export"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||
let dir = temp_dir("wrong-password");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||
assert!(
|
||||
err.contains("Wrong password"),
|
||||
"unexpected message: {}",
|
||||
err
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
///
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||
/// WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
let settings = state.settings_store.get();
|
||||
build_claude_terminal_cmd(
|
||||
project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
session_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell line run immediately before `exec claude` in every Claude terminal
|
||||
/// session.
|
||||
///
|
||||
/// `container/entrypoint.sh` already runs `claude update` when the container
|
||||
/// starts, but containers here use a stop/start (and often just keep running)
|
||||
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
||||
/// it per session is what keeps a week-old container current.
|
||||
///
|
||||
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
||||
/// network, npm registry down) so a session always opens, and `timeout 60`
|
||||
/// bounds how long a user waits for a terminal.
|
||||
///
|
||||
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
||||
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
||||
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
||||
/// races two updaters against the same `~/.claude/bin` install, as does
|
||||
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
||||
/// behind a friendly message and the very next line (`exec claude`) would run
|
||||
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
||||
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
||||
/// other holder just updated, so there is nothing left to do.
|
||||
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
||||
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
||||
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
||||
);
|
||||
|
||||
/// Single-quote one argument for interpolation into a shell script string.
|
||||
fn shell_quote_arg(arg: &str) -> String {
|
||||
format!(" '{}'", arg.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||
/// profile rather than the whole [`AppState`].
|
||||
fn build_claude_terminal_cmd(
|
||||
project: &Project,
|
||||
global_aws_profile: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default();
|
||||
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
if let Some(name) = session_name {
|
||||
if !name.is_empty() {
|
||||
cmd.push("-n".to_string());
|
||||
cmd.push(name.to_string());
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||
.unwrap_or_default();
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one (same escaping style as name_flag above).
|
||||
let permission_flags: String = permission_args
|
||||
.iter()
|
||||
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
@@ -75,9 +114,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
@@ -196,31 +237,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 +305,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 +363,156 @@ pub async fn stop_audio_bridge(
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||
use crate::models::Project;
|
||||
|
||||
/// 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"`).
|
||||
/// A `Project` with only the fields these tests care about set; the rest
|
||||
/// come through serde so the test does not have to track every field.
|
||||
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "Test",
|
||||
"paths": [],
|
||||
"container_id": null,
|
||||
"status": "running",
|
||||
"backend": backend,
|
||||
"bedrock_config": bedrock_config,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"full_permissions": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"created_at": "now",
|
||||
"updated_at": "now"
|
||||
}))
|
||||
.expect("test project deserializes")
|
||||
}
|
||||
|
||||
/// Every Claude session updates the CLI before launching it.
|
||||
///
|
||||
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||
/// containers are long-lived, so a stale CLI is the normal case without
|
||||
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||
/// wrapper rather than a bare `claude` argv.
|
||||
#[test]
|
||||
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
assert_eq!(cmd[1], "-c");
|
||||
assert!(
|
||||
cmd[2].contains(UPDATE_PRELUDE),
|
||||
"plain path must run the update prelude: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||
// The update has to happen *before* the exec, which never returns.
|
||||
assert!(
|
||||
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(
|
||||
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||
"the update must stay time-bounded and non-fatal"
|
||||
);
|
||||
}
|
||||
|
||||
/// The session name is interpolated into a shell script, so a quote in it
|
||||
/// must not break out of its single-quoted argument.
|
||||
#[test]
|
||||
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("anthropic", serde_json::Value::Null),
|
||||
None,
|
||||
Some("Bob's tab; rm -rf /"),
|
||||
);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||
"session name must be single-quote escaped: {}",
|
||||
cmd[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Permission flags travel the same escaped path, and an empty name adds
|
||||
/// no `-n` at all.
|
||||
#[test]
|
||||
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||
"got: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||
}
|
||||
|
||||
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
||||
/// prelude, immediately before the exec.
|
||||
#[test]
|
||||
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("bedrock", serde_json::json!({
|
||||
"auth_method": "profile",
|
||||
"aws_region": "us-east-1",
|
||||
"aws_profile": "acme",
|
||||
"model_id": null,
|
||||
"disable_prompt_caching": false
|
||||
})),
|
||||
None,
|
||||
Some("it's fine"),
|
||||
);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
let script = &cmd[2];
|
||||
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
||||
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
||||
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
||||
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
||||
assert!(
|
||||
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
script
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
assert_eq!(
|
||||
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
||||
"latest.log"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/Downloads/").is_err(),
|
||||
"a directory is not a file to drop"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/..").is_err(),
|
||||
"the name becomes a tar entry, a container path and an argv element"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
@@ -301,21 +301,10 @@ impl ExecSessionManager {
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Build a tar archive in memory containing the file
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -333,40 +322,85 @@ impl ExecSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
///
|
||||
/// The file goes through host RAM twice — once as bytes, once inside the tar —
|
||||
/// so this is a memory bound, and it is checked against the *descriptor* that
|
||||
/// was opened rather than a `metadata` call that described whatever the path
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let data = std::fs::read(&host_path)
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, &dest_for_blk, &data[..])
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
&file,
|
||||
std::path::Path::new(&host_path),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(tar_buf)
|
||||
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
@@ -376,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -384,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -402,20 +446,10 @@ pub async fn upload_bytes_to_container(
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
|
||||
// is read back as root. The mtime still gets stamped so the file doesn't
|
||||
// read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -432,6 +466,74 @@ pub async fn upload_bytes_to_container(
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Build an in-memory tar archive holding a single regular file.
|
||||
///
|
||||
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
|
||||
/// them and Docker's archive extractor honours the header verbatim: a header
|
||||
/// left at the defaults lands the file inside the container as `root:root`
|
||||
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
|
||||
/// listing. Callers that upload on a user's behalf should pass the container
|
||||
/// user's ids from [`container_user_ids`].
|
||||
pub fn build_single_file_tar(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
uid: u64,
|
||||
gid: u64,
|
||||
mtime: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_uid(uid);
|
||||
header.set_gid(gid);
|
||||
header.set_mtime(mtime);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
Ok(tar_buf)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch, for a tar header mtime.
|
||||
pub fn now_epoch_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The numeric uid/gid of the container's `claude` user.
|
||||
///
|
||||
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
|
||||
/// ids on Unix so bind-mounted project files stay writable, and deliberately
|
||||
/// does not on Windows. So the only reliable answer comes from asking the
|
||||
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
let out = exec_oneshot_limited(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||
match (ids.next(), ids.next()) {
|
||||
(Some(uid), Some(gid)) => (uid, gid),
|
||||
_ => (1000, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||
/// host process.
|
||||
///
|
||||
@@ -450,14 +552,31 @@ pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
/// past anything genuine, far short of a problem.
|
||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
||||
/// limit is exceeded, at which point the caller must stop reading.
|
||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
||||
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||
///
|
||||
/// The byte count on its own is a fact about the transport, not about what the
|
||||
/// user did — "Command output exceeded 8388608 bytes" is not a sentence anybody
|
||||
/// can act on. A caller that knows what it was reading can recognise this and
|
||||
/// say the useful thing instead; see `list_container_files`, where the real
|
||||
/// cause is a directory with more entries than the panel can render.
|
||||
pub const OUTPUT_LIMIT_MARKER: &str = "OUTPUT_LIMIT";
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`, returning the range the chunk
|
||||
/// now occupies. `None` once the limit is exceeded, at which point the caller
|
||||
/// must stop reading — and nothing is appended, so a caller that ignored the
|
||||
/// answer cannot parse a half-read document.
|
||||
///
|
||||
/// Bytes rather than `str` on purpose: Docker frames a stream wherever it
|
||||
/// likes, so a chunk boundary can fall inside a UTF-8 sequence. Decoding each
|
||||
/// chunk on its own turned that into two replacement characters in the middle
|
||||
/// of a filename; the decode happens once, at the end, over the whole buffer.
|
||||
fn push_capped(buf: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Option<(usize, usize)> {
|
||||
if buf.len() + chunk.len() > limit {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
buf.push_str(chunk);
|
||||
true
|
||||
let start = buf.len();
|
||||
buf.extend_from_slice(chunk);
|
||||
Some((start, buf.len()))
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
@@ -521,6 +640,65 @@ pub async fn exec_oneshot_as(
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
/// every existing caller reads, and the right one for surfacing "why did that
|
||||
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||
/// caller that is *parsing* output can have just that without the buffer being
|
||||
/// held twice.
|
||||
struct OneshotOutput {
|
||||
combined: Vec<u8>,
|
||||
stdout_ranges: Vec<(usize, usize)>,
|
||||
exit_code: i64,
|
||||
}
|
||||
|
||||
impl OneshotOutput {
|
||||
/// Everything the command printed, in the order it printed it.
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.combined).into_owned()
|
||||
}
|
||||
|
||||
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||
/// mid-record is a parse error at best.
|
||||
fn stdout(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[*start..*end]);
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||
fn stderr(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[cursor..*start]);
|
||||
cursor = *end;
|
||||
}
|
||||
out.extend_from_slice(&self.combined[cursor..]);
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_as`] with the two streams kept apart, for callers that parse
|
||||
/// stdout.
|
||||
///
|
||||
/// `find`'s own diagnostics ("Permission denied") used to arrive inside the
|
||||
/// records its `-printf` was emitting. GNU `find` escapes tabs and newlines in
|
||||
/// those messages, so the listing parser held — but "the parser holds" is not
|
||||
/// the same as "the input is trustworthy", and the fix costs one enum match.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_inner(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
@@ -528,6 +706,17 @@ async fn exec_oneshot_inner(
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, limit).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_raw(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
@@ -550,22 +739,31 @@ async fn exec_oneshot_inner(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let mut combined = String::new();
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => {
|
||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
||||
if !push_capped(&mut combined, &chunk, limit) {
|
||||
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||
let bytes = data.into_bytes();
|
||||
match push_capped(&mut combined, &bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Command output exceeded {} bytes and was abandoned",
|
||||
limit
|
||||
));
|
||||
"{}: 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The terminal file viewer: one OS window per clicked path.
|
||||
//!
|
||||
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
|
||||
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
|
||||
//! the caller's own entry, which is why nothing here takes a path from a window.
|
||||
//!
|
||||
//! `file-viewer-*` is also the `windows` glob of `capabilities/file-viewer.json`, which grants
|
||||
//! exactly the five `viewer_*` commands and nothing else. Labels are minted only here; a window
|
||||
//! created anywhere else with a matching label would inherit those grants.
|
||||
|
||||
pub mod poll;
|
||||
pub mod registry;
|
||||
pub mod resolve;
|
||||
pub mod window;
|
||||
pub mod write;
|
||||
|
||||
/// Spec §3: the 21st click is refused with a toast.
|
||||
pub const MAX_VIEWER_WINDOWS: usize = 20;
|
||||
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
|
||||
|
||||
pub fn is_viewer_label(label: &str) -> bool {
|
||||
label
|
||||
.strip_prefix(VIEWER_LABEL_PREFIX)
|
||||
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_numbered_viewer_labels_pass() {
|
||||
assert!(is_viewer_label("file-viewer-1"));
|
||||
assert!(is_viewer_label("file-viewer-20"));
|
||||
assert!(!is_viewer_label("file-viewer-"));
|
||||
assert!(!is_viewer_label("file-viewer-x"));
|
||||
assert!(!is_viewer_label("main"));
|
||||
assert!(!is_viewer_label("browser-view-abc"));
|
||||
}
|
||||
|
||||
/// Both Vite's dev server and Tauri's asset lookup fall back to `index.html`
|
||||
/// when `viewer.html` is missing, so a broken entry opens the *main app* in
|
||||
/// the viewer window with no error anywhere. Pin the two files the entry needs.
|
||||
#[test]
|
||||
fn the_viewer_entry_exists_and_is_a_vite_input() {
|
||||
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let html = std::fs::read_to_string(app_dir.join("viewer.html")).expect("app/viewer.html");
|
||||
assert!(html.contains("/src/viewer/main.tsx"));
|
||||
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
|
||||
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
|
||||
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Capability {
|
||||
windows: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Task 12: a substring check on the capability JSON (the form this test used to take)
|
||||
/// only proves a permission string appears *somewhere* in the file — it would not catch
|
||||
/// `windows` widened past `file-viewer-*`, nor an extra grant slipped in beside the ones
|
||||
/// this window actually needs. Parse both capability files and pin `windows`/`permissions`
|
||||
/// exactly, so a later widening of either file is a failing test, not a silent threat-model
|
||||
/// drift — this file *is* the reviewed threat model of record (see its own description).
|
||||
#[test]
|
||||
fn the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions() {
|
||||
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json"))
|
||||
.expect("capabilities/file-viewer.json");
|
||||
let cap: Capability = serde_json::from_str(&raw).expect("file-viewer.json must be valid JSON");
|
||||
|
||||
assert_eq!(cap.windows, vec!["file-viewer-*"]);
|
||||
|
||||
let mut permissions = cap.permissions;
|
||||
permissions.sort();
|
||||
assert_eq!(
|
||||
permissions,
|
||||
vec![
|
||||
// App commands (bare): the five viewer commands, and nothing else — build.rs
|
||||
// refuses any other bare grant in this file.
|
||||
"allow-viewer-choose-file",
|
||||
"allow-viewer-get-state",
|
||||
"allow-viewer-poll-file",
|
||||
"allow-viewer-read-file",
|
||||
"allow-viewer-write-file",
|
||||
// Plugin/core grants, unchanged.
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"core:window:allow-destroy",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The main window's capability file must stay scoped to `main` — a `windows` list that
|
||||
/// grew to include `file-viewer-*` would hand every viewer window the dialog/store surface
|
||||
/// `default.json` grants `main`, which is a much larger IPC surface than the one
|
||||
/// `file-viewer.json` was deliberately kept small.
|
||||
#[test]
|
||||
fn the_default_capability_is_scoped_to_the_main_window_only() {
|
||||
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/default.json"))
|
||||
.expect("capabilities/default.json");
|
||||
let cap: Capability = serde_json::from_str(&raw).expect("default.json must be valid JSON");
|
||||
|
||||
assert_eq!(cap.windows, vec!["main"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! One cheap exec per tick: the file's full hash and size, or "gone".
|
||||
//!
|
||||
//! This is what the 2 s poll asks, instead of re-downloading up to 1 MiB of archive per
|
||||
//! window per tick. The hash is coreutils `sha256sum`, which equals `write::sha256_hex`
|
||||
//! of the bytes whenever the read was not truncated — the only case in which the
|
||||
//! editor uses a hash as its save base.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ViewerPoll {
|
||||
pub exists: bool,
|
||||
pub hash: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Exit 4 = gone. A failure after `test -f` passed is re-checked: if the file vanished
|
||||
/// in between (deleted while being hashed), that is "gone", not an error (M6).
|
||||
pub const POLL_SCRIPT: &str = r#"test -f "$1" || exit 4
|
||||
sha256sum -- "$1" && stat -c %s -- "$1" && exit 0
|
||||
test -f "$1" || exit 4
|
||||
exit 1"#;
|
||||
|
||||
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
|
||||
if code == 4 {
|
||||
return ViewerPoll { exists: false, hash: None, size: None };
|
||||
}
|
||||
let mut lines = stdout.lines();
|
||||
let hash = lines
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
// GNU `sha256sum` prefixes the line with `\` when the name contains a
|
||||
// backslash or a newline; strip it before validating the hex (P15).
|
||||
.map(|h| h.trim_start_matches('\\'))
|
||||
.filter(|h| super::write::is_sha256_hex(h))
|
||||
.map(str::to_string);
|
||||
let size = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
|
||||
ViewerPoll { exists: true, hash, size }
|
||||
}
|
||||
|
||||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
|
||||
let cmd = vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
POLL_SCRIPT.to_string(),
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
"Could not check the file: {}",
|
||||
crate::commands::file_commands::clip_container_text(&stderr)
|
||||
));
|
||||
}
|
||||
Ok(parse_poll_output(code, &stdout))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_present_file_yields_hash_and_size() {
|
||||
let out = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\n42\n";
|
||||
assert_eq!(
|
||||
parse_poll_output(0, out),
|
||||
ViewerPoll {
|
||||
exists: true,
|
||||
hash: Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into()),
|
||||
size: Some(42)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_four_means_gone() {
|
||||
assert_eq!(parse_poll_output(4, ""), ViewerPoll { exists: false, hash: None, size: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_not_a_hash() {
|
||||
let p = parse_poll_output(0, "not a hash /x\nabc\n");
|
||||
assert_eq!(p, ViewerPoll { exists: true, hash: None, size: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_tests_existence_before_hashing() {
|
||||
assert!(POLL_SCRIPT.contains("test -f \"$1\" || exit 4"));
|
||||
assert!(POLL_SCRIPT.contains("sha256sum -- \"$1\""));
|
||||
assert!(POLL_SCRIPT.contains("stat -c %s -- \"$1\""));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_poll_script(path_env: Option<&str>, target: &std::path::Path) -> (i64, String, String) {
|
||||
let mut cmd = std::process::Command::new("sh");
|
||||
if let Some(p) = path_env {
|
||||
cmd.env("PATH", p);
|
||||
}
|
||||
let out = cmd.arg("-c").arg(POLL_SCRIPT).arg("poll").arg(target).output().unwrap();
|
||||
(
|
||||
out.status.code().unwrap_or(-1) as i64,
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn test_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tc-poll-{}-{}", name, uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_the_poll_script_reports_hash_size_and_gone() {
|
||||
let dir = test_dir("plain");
|
||||
let target = dir.join("t.txt");
|
||||
std::fs::write(&target, b"hello\n").unwrap();
|
||||
let (code, stdout, stderr) = run_poll_script(None, &target);
|
||||
assert_eq!(code, 0, "stderr={stderr}");
|
||||
let p = parse_poll_output(code, &stdout);
|
||||
assert_eq!(p.hash.as_deref(), Some(super::super::write::sha256_hex(b"hello\n").as_str()));
|
||||
assert_eq!(p.size, Some(6));
|
||||
|
||||
let (code, _, _) = run_poll_script(None, &dir.join("missing"));
|
||||
assert_eq!(code, 4);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// M6: the file is deleted after `test -f` passed but before `sha256sum` read it
|
||||
/// (a `sha256sum` shim on PATH deletes it and fails). That is "gone", not an error
|
||||
/// the viewer would have to explain.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_file_deleted_mid_poll_reads_as_gone() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = test_dir("race");
|
||||
let bin = dir.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let shim = bin.join("sha256sum");
|
||||
std::fs::write(&shim, "#!/bin/sh\nrm -f -- \"$2\"\necho 'sha256sum: No such file or directory' >&2\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let target = dir.join("t.txt");
|
||||
std::fs::write(&target, b"x").unwrap();
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
|
||||
|
||||
let (code, stdout, stderr) = run_poll_script(Some(&path), &target);
|
||||
|
||||
assert_eq!(code, 4, "stderr={stderr}");
|
||||
assert_eq!(parse_poll_output(code, &stdout), ViewerPoll { exists: false, hash: None, size: None });
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A failure with the file still present stays a real error (exit 1), which
|
||||
/// `poll_file` turns into "Could not check the file: …".
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_hash_failure_on_a_present_file_is_an_error() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = test_dir("fail");
|
||||
let bin = dir.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let shim = bin.join("sha256sum");
|
||||
std::fs::write(&shim, "#!/bin/sh\necho 'sha256sum: Permission denied' >&2\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let target = dir.join("t.txt");
|
||||
std::fs::write(&target, b"x").unwrap();
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
|
||||
|
||||
let (code, _stdout, stderr) = run_poll_script(Some(&path), &target);
|
||||
|
||||
assert_eq!(code, 1, "stderr={stderr}");
|
||||
assert!(stderr.contains("Permission denied"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// P15: a path containing a backslash makes GNU `sha256sum` prefix the whole
|
||||
/// line with `\`; that must not blind change detection by yielding `hash: None`.
|
||||
#[test]
|
||||
fn a_backslash_prefixed_hash_is_still_recognised() {
|
||||
let out = "\\e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\\y\n7\n";
|
||||
let p = parse_poll_output(0, out);
|
||||
assert_eq!(
|
||||
p.hash.as_deref(),
|
||||
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
);
|
||||
assert_eq!(p.size, Some(7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Which viewer window is looking at what.
|
||||
//!
|
||||
//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on
|
||||
//! `AppState`, like the browser view keeps its own state. A label is reserved *before*
|
||||
//! the window is built so two concurrent clicks cannot both pass the cap check.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct Location {
|
||||
pub line: Option<u32>,
|
||||
pub col: Option<u32>,
|
||||
pub end_line: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ViewerTargetState {
|
||||
Resolved { container_path: String },
|
||||
Choose { candidates: Vec<String> },
|
||||
NotFound { tried: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ViewerTarget {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub raw_path: String,
|
||||
pub state: ViewerTargetState,
|
||||
pub initial: Location,
|
||||
}
|
||||
|
||||
/// What [`ViewerRegistry::reserve`] decided.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Reservation {
|
||||
/// A window is already registered on this file. `built` is false while that
|
||||
/// window is still being created: it has no `WebviewWindow` to focus yet, and
|
||||
/// it will open at its own location, so the caller should simply return.
|
||||
Existing { label: String, built: bool },
|
||||
/// A new label, registered and counted against the cap; build its window,
|
||||
/// then call [`ViewerRegistry::mark_built`] (or `remove` if building failed).
|
||||
Reserved(String),
|
||||
}
|
||||
|
||||
/// What [`ViewerRegistry::choose`] decided.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
/// The caller's entry now points at the chosen file.
|
||||
Resolved(ViewerTarget),
|
||||
/// Another window already has that file; the caller's entry is unchanged.
|
||||
AlreadyOpen { label: String, built: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
target: ViewerTarget,
|
||||
/// Set once the window's `build()` has returned. Until then the label has no
|
||||
/// window by design, so "registered but windowless" means "being built", not
|
||||
/// "stale" — only built entries are ever pruned.
|
||||
built: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ViewerRegistry {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
next: AtomicU64,
|
||||
}
|
||||
|
||||
fn same_file(t: &ViewerTarget, project_id: &str, container_path: &str) -> bool {
|
||||
t.project_id == project_id
|
||||
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
|
||||
}
|
||||
|
||||
fn open_on(
|
||||
entries: &HashMap<String, Entry>,
|
||||
project_id: &str,
|
||||
container_path: &str,
|
||||
except: Option<&str>,
|
||||
) -> Option<(String, bool)> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(label, e)| Some(label.as_str()) != except && same_file(&e.target, project_id, container_path))
|
||||
.map(|(label, e)| (label.clone(), e.built))
|
||||
}
|
||||
|
||||
/// Drops built entries whose window is gone, whatever their state. `Destroyed`
|
||||
/// normally removes an entry; this is the backstop for one it missed, so a leak
|
||||
/// can never hold a cap slot for good.
|
||||
fn prune(entries: &mut HashMap<String, Entry>, is_live: &dyn Fn(&str) -> bool) {
|
||||
entries.retain(|label, e| !e.built || is_live(label));
|
||||
}
|
||||
|
||||
impl ViewerRegistry {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
|
||||
self.entries.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Finds the window already open on a resolved target, or reserves a label,
|
||||
/// in one critical section, after pruning built entries `is_live` says are
|
||||
/// gone. `is_live` runs under the registry lock and must not call back into
|
||||
/// the registry.
|
||||
pub fn reserve(
|
||||
&self,
|
||||
target: ViewerTarget,
|
||||
is_live: impl Fn(&str) -> bool,
|
||||
) -> Result<Reservation, String> {
|
||||
let mut entries = self.lock();
|
||||
prune(&mut entries, &is_live);
|
||||
if let ViewerTargetState::Resolved { container_path } = &target.state {
|
||||
if let Some((label, built)) = open_on(&entries, &target.project_id, container_path, None) {
|
||||
return Ok(Reservation::Existing { label, built });
|
||||
}
|
||||
}
|
||||
if entries.len() >= MAX_VIEWER_WINDOWS {
|
||||
return Err(format!(
|
||||
"{} file windows are already open — close one before opening another.",
|
||||
MAX_VIEWER_WINDOWS
|
||||
));
|
||||
}
|
||||
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
|
||||
entries.insert(label.clone(), Entry { target, built: false });
|
||||
Ok(Reservation::Reserved(label))
|
||||
}
|
||||
|
||||
/// Records that `label`'s window exists. A no-op if it was already removed
|
||||
/// (a window destroyed the moment it appeared).
|
||||
pub fn mark_built(&self, label: &str) {
|
||||
if let Some(e) = self.lock().get_mut(label) {
|
||||
e.built = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Points `label`'s entry at `container_path`, unless another window already
|
||||
/// has that file open — then the entry is left alone, so no two entries are
|
||||
/// ever resolved to the same file.
|
||||
pub fn choose(
|
||||
&self,
|
||||
label: &str,
|
||||
container_path: String,
|
||||
is_live: impl Fn(&str) -> bool,
|
||||
) -> Result<Choice, String> {
|
||||
let mut entries = self.lock();
|
||||
prune(&mut entries, &is_live);
|
||||
let project_id = entries
|
||||
.get(label)
|
||||
.ok_or_else(|| "This file window is no longer registered.".to_string())?
|
||||
.target
|
||||
.project_id
|
||||
.clone();
|
||||
if let Some((other, built)) = open_on(&entries, &project_id, &container_path, Some(label)) {
|
||||
return Ok(Choice::AlreadyOpen { label: other, built });
|
||||
}
|
||||
let entry = entries.get_mut(label).expect("checked above under the same lock");
|
||||
entry.target.state = ViewerTargetState::Resolved { container_path };
|
||||
Ok(Choice::Resolved(entry.target.clone()))
|
||||
}
|
||||
|
||||
pub fn get(&self, label: &str) -> Option<ViewerTarget> {
|
||||
self.lock().get(label).map(|e| e.target.clone())
|
||||
}
|
||||
|
||||
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
|
||||
let mut entries = self.lock();
|
||||
let entry = entries
|
||||
.get_mut(label)
|
||||
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
|
||||
entry.target.state = state;
|
||||
Ok(entry.target.clone())
|
||||
}
|
||||
|
||||
pub fn remove(&self, label: &str) {
|
||||
self.lock().remove(label);
|
||||
}
|
||||
|
||||
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
|
||||
open_on(&self.lock(), project_id, container_path, None).map(|(label, _)| label)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lock().len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(project: &str, path: &str) -> ViewerTarget {
|
||||
ViewerTarget {
|
||||
project_id: project.into(),
|
||||
project_name: "Demo".into(),
|
||||
raw_path: path.into(),
|
||||
state: ViewerTargetState::Resolved { container_path: path.into() },
|
||||
initial: Location { line: Some(3), col: None, end_line: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn all_live(_: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Reserves a label that must be new.
|
||||
fn fresh(r: &ViewerRegistry, t: ViewerTarget) -> String {
|
||||
match r.reserve(t, all_live).unwrap() {
|
||||
Reservation::Reserved(label) => label,
|
||||
other => panic!("expected a new label, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn choosing(project: &str, candidates: &[&str]) -> ViewerTarget {
|
||||
ViewerTarget {
|
||||
state: ViewerTargetState::Choose { candidates: candidates.iter().map(|c| c.to_string()).collect() },
|
||||
..target(project, "a")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_sequential_and_never_reused() {
|
||||
let r = ViewerRegistry::default();
|
||||
let a = fresh(&r, target("p", "/workspace/a"));
|
||||
let b = fresh(&r, target("p", "/workspace/b"));
|
||||
assert_eq!(a, "file-viewer-1");
|
||||
assert_eq!(b, "file-viewer-2");
|
||||
r.remove(&a);
|
||||
let c = fresh(&r, target("p", "/workspace/c"));
|
||||
assert_eq!(c, "file-viewer-3");
|
||||
assert_eq!(r.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cap_refuses_the_twenty_first_window() {
|
||||
let r = ViewerRegistry::default();
|
||||
for i in 0..MAX_VIEWER_WINDOWS {
|
||||
fresh(&r, target("p", &format!("/workspace/{}", i)));
|
||||
}
|
||||
let err = r.reserve(target("p", "/workspace/one-more"), all_live).unwrap_err();
|
||||
assert!(err.contains("20"), "{}", err);
|
||||
assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_resolved_file_is_found_by_project_and_path() {
|
||||
let r = ViewerRegistry::default();
|
||||
let label = fresh(&r, target("p", "/workspace/a"));
|
||||
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
|
||||
assert_eq!(r.find_open("other", "/workspace/a"), None);
|
||||
// A window still choosing is not "open on" any path.
|
||||
r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap();
|
||||
assert_eq!(r.find_open("p", "/workspace/a"), None);
|
||||
r.remove(&label);
|
||||
assert_eq!(r.get(&label), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_state_on_an_unknown_label_is_an_error() {
|
||||
let r = ViewerRegistry::default();
|
||||
assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_state_serialises_with_a_kind_tag() {
|
||||
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
|
||||
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
|
||||
}
|
||||
|
||||
/// I1: a second click while the first window is still being built must find
|
||||
/// that window, not read it as stale and reserve a second one.
|
||||
#[test]
|
||||
fn a_window_being_built_is_found_not_replaced() {
|
||||
let r = ViewerRegistry::default();
|
||||
let a = fresh(&r, target("p", "/workspace/a"));
|
||||
// No window exists yet for `a`: `is_live` says so, and it must not matter.
|
||||
let second = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
|
||||
assert_eq!(second, Reservation::Existing { label: a.clone(), built: false });
|
||||
assert!(r.get(&a).is_some());
|
||||
assert_eq!(r.len(), 1);
|
||||
|
||||
r.mark_built(&a);
|
||||
let third = r.reserve(target("p", "/workspace/a"), all_live).unwrap();
|
||||
assert_eq!(third, Reservation::Existing { label: a, built: true });
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
/// A built entry whose window is gone is stale: pruned, and the file reopens.
|
||||
#[test]
|
||||
fn a_built_entry_without_a_window_is_pruned_and_the_file_reopens() {
|
||||
let r = ViewerRegistry::default();
|
||||
let a = fresh(&r, target("p", "/workspace/a"));
|
||||
r.mark_built(&a);
|
||||
let again = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
|
||||
assert_eq!(again, Reservation::Reserved("file-viewer-2".into()));
|
||||
assert_eq!(r.get(&a), None);
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
/// M2: a leaked entry of any state cannot hold a cap slot once built and gone,
|
||||
/// and an entry still being built always keeps its slot.
|
||||
#[test]
|
||||
fn leaked_entries_of_every_state_free_their_cap_slot() {
|
||||
let r = ViewerRegistry::default();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..MAX_VIEWER_WINDOWS {
|
||||
let t = match i % 3 {
|
||||
0 => target("p", &format!("/workspace/{}", i)),
|
||||
1 => choosing("p", &["/workspace/x", "/workspace/y"]),
|
||||
_ => ViewerTarget { state: ViewerTargetState::NotFound { tried: vec![] }, ..target("p", "z") },
|
||||
};
|
||||
labels.push(fresh(&r, t));
|
||||
}
|
||||
// All still being built: none may be pruned, so the cap holds.
|
||||
assert!(r.reserve(target("p", "/workspace/new"), |_| false).is_err());
|
||||
for l in &labels {
|
||||
r.mark_built(l);
|
||||
}
|
||||
// Built, and one of each state has lost its window.
|
||||
let dead = [labels[0].clone(), labels[1].clone(), labels[2].clone()];
|
||||
let live = |l: &str| !dead.iter().any(|d| d == l);
|
||||
assert!(matches!(r.reserve(target("p", "/workspace/new"), live), Ok(Reservation::Reserved(_))));
|
||||
assert_eq!(r.len(), MAX_VIEWER_WINDOWS - 2);
|
||||
for d in &dead {
|
||||
assert_eq!(r.get(d), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_built_on_a_removed_label_is_a_no_op() {
|
||||
let r = ViewerRegistry::default();
|
||||
let a = fresh(&r, target("p", "/workspace/a"));
|
||||
r.remove(&a);
|
||||
r.mark_built(&a);
|
||||
assert_eq!(r.get(&a), None);
|
||||
}
|
||||
|
||||
/// M5: choosing a file another window already has leaves the chooser alone,
|
||||
/// so two entries are never resolved to the same file.
|
||||
#[test]
|
||||
fn choosing_a_file_open_elsewhere_does_not_resolve_a_second_entry() {
|
||||
let r = ViewerRegistry::default();
|
||||
let open = fresh(&r, target("p", "/workspace/x"));
|
||||
r.mark_built(&open);
|
||||
let chooser = fresh(&r, choosing("p", &["/workspace/x", "/workspace/y"]));
|
||||
r.mark_built(&chooser);
|
||||
|
||||
let c = r.choose(&chooser, "/workspace/x".into(), all_live).unwrap();
|
||||
assert_eq!(c, Choice::AlreadyOpen { label: open.clone(), built: true });
|
||||
assert!(matches!(r.get(&chooser).unwrap().state, ViewerTargetState::Choose { .. }));
|
||||
|
||||
match r.choose(&chooser, "/workspace/y".into(), all_live).unwrap() {
|
||||
Choice::Resolved(t) => assert_eq!(t.state, ViewerTargetState::Resolved { container_path: "/workspace/y".into() }),
|
||||
other => panic!("expected Resolved, got {:?}", other),
|
||||
}
|
||||
assert_eq!(r.find_open("p", "/workspace/y"), Some(chooser));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_the_same_path_in_another_project_is_not_a_duplicate() {
|
||||
let r = ViewerRegistry::default();
|
||||
fresh(&r, target("other", "/workspace/x"));
|
||||
let chooser = fresh(&r, choosing("p", &["/workspace/x"]));
|
||||
assert!(matches!(r.choose(&chooser, "/workspace/x".into(), all_live), Ok(Choice::Resolved(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_on_an_unknown_label_is_an_error() {
|
||||
let r = ViewerRegistry::default();
|
||||
assert!(r.choose("file-viewer-9", "/workspace/x".into(), all_live).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Turning what Claude printed into a container path that exists.
|
||||
//!
|
||||
//! Relative paths are the common case (Claude prints project-relative paths). The
|
||||
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
|
||||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
|
||||
//! is one exec as the container user and prints `realpath -e` of every candidate that
|
||||
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
|
||||
//! hold the resolved path, not the one that was clicked.
|
||||
|
||||
use crate::commands::file_commands::validate_container_path;
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
|
||||
pub const MAX_CANDIDATES: usize = 16;
|
||||
const MAX_RAW_LEN: usize = 4096;
|
||||
|
||||
/// `$@` are the candidates. For each regular file, print its resolved path.
|
||||
pub const PROBE_SCRIPT: &str = r#"for c in "$@"; do if test -f "$c"; then realpath -e -- "$c" 2>/dev/null; fi; done; exit 0"#;
|
||||
|
||||
pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String> {
|
||||
if raw.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
}
|
||||
if raw.len() > MAX_RAW_LEN {
|
||||
return Err("The path is too long.".into());
|
||||
}
|
||||
if raw.contains('\0') {
|
||||
return Err("The path contains a NUL byte.".into());
|
||||
}
|
||||
if raw.split('/').any(|seg| seg == "..") {
|
||||
return Err(format!("{} climbs out of its folder with `..`; refusing.", raw));
|
||||
}
|
||||
|
||||
if raw.starts_with('/') {
|
||||
let normalised = collapse(raw);
|
||||
validate_container_path("File", &normalised)?;
|
||||
return Ok(vec![normalised]);
|
||||
}
|
||||
|
||||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||||
let rel = rel.trim_start_matches("./");
|
||||
if rel.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |candidate: String| {
|
||||
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
|
||||
out.push(candidate);
|
||||
}
|
||||
};
|
||||
push(format!("/workspace/{}", rel));
|
||||
for mount in mount_names {
|
||||
if mount.is_empty() || mount.contains('/') || mount == "." || mount == ".." {
|
||||
continue;
|
||||
}
|
||||
push(format!("/workspace/{}/{}", mount, rel));
|
||||
}
|
||||
for c in &out {
|
||||
validate_container_path("File", c)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
|
||||
fn collapse(path: &str) -> String {
|
||||
let absolute = path.starts_with('/');
|
||||
let joined = path
|
||||
.split('/')
|
||||
.filter(|seg| !seg.is_empty() && *seg != ".")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if absolute { format!("/{}", joined) } else { joined }
|
||||
}
|
||||
|
||||
/// One resolved path per line; anything that is not an absolute, valid container path is
|
||||
/// dropped (the script's own diagnostics go to stderr, but a hostile `realpath` output is
|
||||
/// still container-authored text).
|
||||
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || validate_container_path("File", line).is_err() {
|
||||
continue;
|
||||
}
|
||||
if !seen.iter().any(|s| s == line) {
|
||||
seen.push(line.to_string());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
pub async fn probe_candidates(
|
||||
container_id: &str,
|
||||
candidates: &[String],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
|
||||
cmd.extend(candidates.iter().cloned());
|
||||
let (stdout, _stderr, _code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
Ok(parse_probe_output(&stdout))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mounts(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_path_is_its_own_only_candidate() {
|
||||
let c = candidate_paths("/workspace/api/src/main.rs", &mounts(&["api"])).unwrap();
|
||||
assert_eq!(c, vec!["/workspace/api/src/main.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_probes_workspace_then_each_mount() {
|
||||
let c = candidate_paths("src/main.rs", &mounts(&["api", "web"])).unwrap();
|
||||
assert_eq!(
|
||||
c,
|
||||
vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_prefix_and_duplicate_slashes_are_normalised_and_candidates_deduped() {
|
||||
let c = candidate_paths("./src//main.rs", &mounts(&["api", "api", ""])).unwrap();
|
||||
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_nul_and_oversize_are_refused() {
|
||||
assert!(candidate_paths("../etc/passwd", &[]).is_err());
|
||||
assert!(candidate_paths("src/../../x", &[]).is_err());
|
||||
assert!(candidate_paths("/workspace/../etc/passwd", &[]).is_err());
|
||||
assert!(candidate_paths("a\0b", &[]).is_err());
|
||||
assert!(candidate_paths("", &[]).is_err());
|
||||
assert!(candidate_paths(&"a".repeat(5000), &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_list_is_capped() {
|
||||
let many: Vec<String> = (0..40).map(|i| format!("m{}", i)).collect();
|
||||
let c = candidate_paths("x.rs", &many).unwrap();
|
||||
assert_eq!(c.len(), MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_output_keeps_valid_resolved_regular_files_only() {
|
||||
let out = "/workspace/api/src/main.rs\n/workspace/api/src/main.rs\n\nrelative/junk\n/etc/../x\n/workspace/web/src/main.rs\n";
|
||||
assert_eq!(
|
||||
parse_probe_output(out),
|
||||
vec!["/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_script_prints_resolved_paths_of_regular_files() {
|
||||
// Shape assertions: the script is data handed to `sh -c`, and these are the
|
||||
// three things a later edit must not lose.
|
||||
assert!(PROBE_SCRIPT.contains("test -f"));
|
||||
assert!(PROBE_SCRIPT.contains("realpath -e --"));
|
||||
assert!(PROBE_SCRIPT.contains("for c in \"$@\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences:
|
||||
//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in
|
||||
//! `capabilities/file-viewer.json` applies; and the registry entry is removed on
|
||||
//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a
|
||||
//! Rust-side `destroy()`.
|
||||
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
use super::registry::ViewerRegistry;
|
||||
|
||||
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> {
|
||||
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into()))
|
||||
.title(title)
|
||||
.inner_size(900.0, 700.0)
|
||||
.min_inner_size(480.0, 320.0)
|
||||
.build()
|
||||
.map_err(|e| format!("Could not open the file window: {}", e))?;
|
||||
|
||||
let app_for_event = app.clone();
|
||||
let label_owned = label.to_string();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
app_for_event.state::<ViewerRegistry>().remove(&label_owned);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
//! Saving: stage in `/tmp`, then swap in as the container user.
|
||||
//!
|
||||
//! The Docker archive API writes as root, so it is used for exactly one thing — landing
|
||||
//! the payload at `/tmp/triple-c-viewer-<uuid>`, owned by the container user (the
|
||||
//! existing `write_file_to_container`). Everything that touches the *target directory*
|
||||
//! runs in an exec as `claude`, so a save can do nothing the user's own shell could not.
|
||||
//! A non-root process cannot `chown`, so the saved file is owned by the container user,
|
||||
//! as it would be after Claude Code edited it; mode is kept with `chmod --reference`.
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::commands::file_commands::clip_container_text;
|
||||
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
|
||||
|
||||
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
|
||||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
digest.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
pub fn is_sha256_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// `$1` target, `$2` staged payload in /tmp, `$3` the hash the editor loaded from.
|
||||
/// Exit 1 = a step failed (unreadable target, a failed stage/replace, …), 3 = changed
|
||||
/// on disk, 4 = gone, 5 = the target is not writable by the container user; stdout on
|
||||
/// success is `sha256sum` of the target *after* the write. That is not necessarily the
|
||||
/// hash of what we wrote: another writer (Claude Code, on the same file) can land
|
||||
/// between `mv` and `sha256sum`. `saved_file` therefore takes the save's base from the
|
||||
/// bytes and only reports this one as what the disk held afterwards (M2).
|
||||
///
|
||||
/// P15: `sha256sum -- "$target"` prefixes its whole line with `\` when the path
|
||||
/// contains a backslash or a newline, so `$actual` has that prefix stripped before
|
||||
/// it is compared with `$expect` (which never carries one) — otherwise such a path
|
||||
/// would conflict forever.
|
||||
///
|
||||
/// I1: `$actual` is read from a plain `sha256sum` command substitution, not a
|
||||
/// pipeline into `cut` — POSIX sh has no `pipefail`, so `cmd | cut … || exit 1` tests
|
||||
/// only `cut`'s exit status and an unreadable file (EACCES, EIO) fell through as a
|
||||
/// false "changed on disk" conflict (empty `$actual` never equals `$expect`) instead
|
||||
/// of a real error, hiding the actual failure from the user and from `classify_write`.
|
||||
///
|
||||
/// I2/M3: `$staged` is created by `mktemp` (exclusive — never follows a planted
|
||||
/// symlink or stale leftover at that name) and is part of the `EXIT` trap from the
|
||||
/// moment it is assigned, so a failure at any later step (`cp`, `chmod`, `mv`) cannot
|
||||
/// leave a partial `.<name>.triple-c-<suffix>` behind in the user's own directory —
|
||||
/// including on a signal, for the steps after the trap covers it.
|
||||
pub const WRITE_SCRIPT: &str = r#"target=$1; tmp=$2; expect=$3
|
||||
staged=
|
||||
trap 'rm -f -- "$tmp" ${staged:+"$staged"}' EXIT
|
||||
test -f "$target" || exit 4
|
||||
actual=$(sha256sum -- "$target") || exit 1
|
||||
actual=${actual%% *}; actual=${actual#\\}
|
||||
[ "$actual" = "$expect" ] || exit 3
|
||||
# I3: the file's own mode is a boundary the user set from outside the container (0444,
|
||||
# a different owning uid, a read-only bind mount, …). Replacing it via rename or
|
||||
# truncating it in place would silently cross that boundary even though `claude` is
|
||||
# allowed to — an editor such as vim, or a plain `echo > file` in the user's own shell,
|
||||
# would refuse. This is stricter than spec §5 step 3's literal "if the directory is
|
||||
# writable" branch, which never looks at the file's own permissions; the branch below
|
||||
# only ever chooses *how* to write, never *whether*.
|
||||
#
|
||||
# The rename branch replaces whatever is at "$target" (a symlink planted there after
|
||||
# the window opened is replaced, not followed). The in-place `cat >` fallback, taken
|
||||
# only for a writable file in a read-only directory, DOES follow such a symlink and
|
||||
# writes through it. That is accepted: the write runs as `claude`, so it can reach
|
||||
# nothing Claude Code in the same container cannot already write.
|
||||
[ -w "$target" ] || { echo "The file is read-only for the container user." >&2; exit 5; }
|
||||
dir=$(dirname -- "$target"); name=$(basename -- "$target")
|
||||
if [ -w "$dir" ]; then
|
||||
staged=$(mktemp -- "$dir/.$name.triple-c-XXXXXX") || exit 1
|
||||
cp -- "$tmp" "$staged" || exit 1
|
||||
chmod --reference="$target" "$staged" 2>/dev/null
|
||||
mv -f -- "$staged" "$target" || exit 1
|
||||
else
|
||||
cat -- "$tmp" > "$target" || exit 1
|
||||
fi
|
||||
sha256sum -- "$target""#;
|
||||
|
||||
/// A save refused because the file changed since its base hash. The frontend matches
|
||||
/// this prefix; its copy lives in `app/src/viewer/ipcMessages.ts` (pinned by a test).
|
||||
pub const CONFLICT_PREFIX: &str = "conflict:";
|
||||
/// A save refused because the file no longer exists; mirrored in `ipcMessages.ts`.
|
||||
pub const GONE_PREFIX: &str = "gone:";
|
||||
/// The read-only refusal. The script echoes the same sentence (pinned by a test), but
|
||||
/// the caller always gets this constant, whatever the script printed; mirrored in
|
||||
/// `ipcMessages.ts`.
|
||||
pub const READ_ONLY_MESSAGE: &str = "The file is read-only for the container user.";
|
||||
|
||||
/// I3: distinct from the generic failure code so the caller can hand back a specific,
|
||||
/// readable message instead of whatever the script's own diagnostic text says.
|
||||
const EXIT_READ_ONLY: i64 = 5;
|
||||
|
||||
pub enum WriteOutcome {
|
||||
Saved(String),
|
||||
Conflict,
|
||||
Gone,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn classify_write(code: i64, stdout: &str, stderr: &str) -> WriteOutcome {
|
||||
match code {
|
||||
3 => WriteOutcome::Conflict,
|
||||
4 => WriteOutcome::Gone,
|
||||
EXIT_READ_ONLY => WriteOutcome::Failed(READ_ONLY_MESSAGE.into()),
|
||||
0 => match stdout
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|h| h.trim_start_matches('\\'))
|
||||
.filter(|h| is_sha256_hex(h))
|
||||
{
|
||||
Some(h) => WriteOutcome::Saved(h.to_string()),
|
||||
None => WriteOutcome::Failed(
|
||||
"The container did not report the saved file's hash.".into(),
|
||||
),
|
||||
},
|
||||
_ => WriteOutcome::Failed(clip_container_text(stderr)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The write script's argv beyond `sh -c SCRIPT`: `$0=save`, `$1=target`, `$2=tmp`,
|
||||
/// `$3=base_hash` — pulled out pure so the argument shape has a unit test (P8).
|
||||
fn write_command(target: &str, tmp: &str, base_hash: &str) -> Vec<String> {
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
WRITE_SCRIPT.to_string(),
|
||||
"save".to_string(),
|
||||
target.to_string(),
|
||||
tmp.to_string(),
|
||||
base_hash.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Refuses a payload too large to be editable, or a malformed base hash, before
|
||||
/// anything is staged in the container (P8).
|
||||
fn check_write_input(len: usize, base_hash: &str) -> Result<(), String> {
|
||||
if len > MAX_WRITE_BYTES {
|
||||
return Err("Files over 1 MiB are read-only in the viewer.".into());
|
||||
}
|
||||
if !is_sha256_hex(base_hash) {
|
||||
return Err("The editor's base hash is malformed; reload the file.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a successful save reports: `hash` is the new base, `sha256_hex` of the bytes
|
||||
/// we wrote; `disk_hash` is what the container hashed right after the swap. They differ
|
||||
/// only when another writer landed in between, and then the editor must show "Changed
|
||||
/// on disk" rather than adopt the other writer's hash as its base (M2).
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct SavedFile {
|
||||
pub hash: String,
|
||||
pub disk_hash: String,
|
||||
}
|
||||
|
||||
/// `viewer_write_file`'s result, pure so the error-prefix contract has a unit test.
|
||||
fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String> {
|
||||
match outcome {
|
||||
WriteOutcome::Saved(disk_hash) => Ok(SavedFile { hash: sha256_hex(bytes), disk_hash }),
|
||||
WriteOutcome::Conflict => Err(format!(
|
||||
"{} the file changed on disk since it was loaded.",
|
||||
CONFLICT_PREFIX
|
||||
)),
|
||||
WriteOutcome::Gone => Err(format!("{} the file no longer exists.", GONE_PREFIX)),
|
||||
WriteOutcome::Failed(msg) => Err(format!("Could not save the file: {}", msg)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_file(
|
||||
container_id: &str,
|
||||
exec_manager: &ExecSessionManager,
|
||||
target: &str,
|
||||
bytes: &[u8],
|
||||
base_hash: &str,
|
||||
) -> Result<SavedFile, String> {
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
let tmp_path = exec_manager
|
||||
.write_file_to_container(container_id, &tmp_name, bytes)
|
||||
.await?;
|
||||
let cmd = write_command(target, &tmp_path, base_hash);
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
saved_file(classify_write(code, &stdout, &stderr), bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_coreutils() {
|
||||
// `printf 'hello\n' | sha256sum`
|
||||
assert_eq!(
|
||||
sha256_hex(b"hello\n"),
|
||||
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
|
||||
);
|
||||
assert!(is_sha256_hex(&sha256_hex(b"")));
|
||||
assert!(!is_sha256_hex("ABC"));
|
||||
assert!(!is_sha256_hex(&"g".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_map_to_outcomes() {
|
||||
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
|
||||
assert!(matches!(classify_write(0, &format!("{} /x\n", h), ""), WriteOutcome::Saved(s) if s == h));
|
||||
assert!(matches!(classify_write(3, "", ""), WriteOutcome::Conflict));
|
||||
assert!(matches!(classify_write(4, "", ""), WriteOutcome::Gone));
|
||||
assert!(matches!(classify_write(1, "", "cp: Permission denied"), WriteOutcome::Failed(m) if m.contains("Permission denied")));
|
||||
// Success without a parseable hash is still a failure: the editor's base would be wrong.
|
||||
assert!(matches!(classify_write(0, "junk", ""), WriteOutcome::Failed(_)));
|
||||
}
|
||||
|
||||
/// I3: exit 5 is the script's read-only refusal, and it must not be swallowed by
|
||||
/// the generic `_ => Failed(stderr)` arm — the caller gets a fixed, readable
|
||||
/// message regardless of exactly what the script printed.
|
||||
#[test]
|
||||
fn exit_five_is_a_distinct_read_only_refusal() {
|
||||
assert!(matches!(
|
||||
classify_write(5, "", "The file is read-only for the container user."),
|
||||
WriteOutcome::Failed(m) if m.contains("read-only")
|
||||
));
|
||||
}
|
||||
|
||||
/// M2: the new base is the hash of the bytes we wrote, never the script's
|
||||
/// post-`mv` hash, which may belong to a writer that landed after us.
|
||||
#[test]
|
||||
fn a_save_takes_its_base_from_the_written_bytes() {
|
||||
let ours = sha256_hex(b"new\n");
|
||||
let same = saved_file(WriteOutcome::Saved(ours.clone()), b"new\n").unwrap();
|
||||
assert_eq!(same, SavedFile { hash: ours.clone(), disk_hash: ours.clone() });
|
||||
|
||||
let foreign = sha256_hex(b"someone else's\n");
|
||||
let raced = saved_file(WriteOutcome::Saved(foreign.clone()), b"new\n").unwrap();
|
||||
assert_eq!(raced.hash, ours, "the base must be what we wrote");
|
||||
assert_eq!(raced.disk_hash, foreign, "the foreign hash is reported, not adopted");
|
||||
}
|
||||
|
||||
/// Important #4: the frontend matches these exact strings
|
||||
/// (`app/src/viewer/ipcMessages.ts`), so pin them here too.
|
||||
#[test]
|
||||
fn save_errors_keep_the_prefix_contract() {
|
||||
let conflict = saved_file(WriteOutcome::Conflict, b"").unwrap_err();
|
||||
assert!(conflict.starts_with("conflict:"), "{conflict}");
|
||||
assert_eq!(conflict, "conflict: the file changed on disk since it was loaded.");
|
||||
|
||||
let gone = saved_file(WriteOutcome::Gone, b"").unwrap_err();
|
||||
assert!(gone.starts_with("gone:"), "{gone}");
|
||||
assert_eq!(gone, "gone: the file no longer exists.");
|
||||
|
||||
let read_only = saved_file(classify_write(5, "", "whatever the script said"), b"").unwrap_err();
|
||||
assert_eq!(read_only, "Could not save the file: The file is read-only for the container user.");
|
||||
assert!(!read_only.starts_with(CONFLICT_PREFIX) && !read_only.starts_with(GONE_PREFIX));
|
||||
|
||||
let other = saved_file(classify_write(1, "", "No space left on device"), b"").unwrap_err();
|
||||
assert_eq!(other, "Could not save the file: No space left on device");
|
||||
|
||||
// The script's own refusal text is the same sentence the caller is given.
|
||||
assert!(WRITE_SCRIPT.contains(&format!("echo \"{}\" >&2; exit 5", READ_ONLY_MESSAGE)));
|
||||
}
|
||||
|
||||
/// The TypeScript side keeps one copy of each matched string; a change on either
|
||||
/// side without the other fails here.
|
||||
#[test]
|
||||
fn the_frontend_copies_of_the_ipc_messages_match() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/viewer/ipcMessages.ts");
|
||||
let ts = std::fs::read_to_string(&path).expect("app/src/viewer/ipcMessages.ts");
|
||||
for (name, value) in [
|
||||
("CONFLICT_PREFIX", CONFLICT_PREFIX),
|
||||
("GONE_PREFIX", GONE_PREFIX),
|
||||
("READ_ONLY_MESSAGE", READ_ONLY_MESSAGE),
|
||||
("NOT_RUNNING_PREFIX", crate::commands::file_commands::NOT_RUNNING_PREFIX),
|
||||
] {
|
||||
let line = format!("export const {} = \"{}\";", name, value);
|
||||
assert!(ts.contains(&line), "ipcMessages.ts must contain `{line}`");
|
||||
}
|
||||
}
|
||||
|
||||
/// P15: a target path with a backslash makes `sha256sum` prefix the line;
|
||||
/// the parsed hash must still be recognised as the saved hash.
|
||||
#[test]
|
||||
fn a_backslash_prefixed_saved_hash_is_still_recognised() {
|
||||
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
|
||||
assert!(matches!(
|
||||
classify_write(0, &format!("\\{} /x\\y\n", h), ""),
|
||||
WriteOutcome::Saved(s) if s == h
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_write_script_checks_then_swaps_and_always_cleans_up() {
|
||||
for needle in [
|
||||
"test -f \"$target\" || exit 4",
|
||||
"exit 3",
|
||||
"chmod --reference=\"$target\"",
|
||||
"mv -f --",
|
||||
"cat -- \"$tmp\" > \"$target\"",
|
||||
// I2/M3: the trap covers the staged file too, and it comes from `mktemp`.
|
||||
"trap 'rm -f -- \"$tmp\" ${staged:+\"$staged\"}' EXIT",
|
||||
"mktemp -- \"$dir/.$name.triple-c-XXXXXX\"",
|
||||
// I1: a plain command substitution, not a pipeline `cut` could mask.
|
||||
"actual=$(sha256sum -- \"$target\") || exit 1",
|
||||
// I3: a read-only target is refused before any write is attempted.
|
||||
"[ -w \"$target\" ] || { echo \"The file is read-only for the container user.\" >&2; exit 5; }",
|
||||
] {
|
||||
assert!(WRITE_SCRIPT.contains(needle), "missing: {}", needle);
|
||||
}
|
||||
// The old pipeline form must be gone, not merely superseded.
|
||||
assert!(!WRITE_SCRIPT.contains("cut -d' ' -f1"));
|
||||
}
|
||||
|
||||
/// P8: the write script's test list is binding, and the argument order is
|
||||
/// exactly what a later edit could silently break.
|
||||
#[test]
|
||||
fn write_command_has_the_expected_argv_shape() {
|
||||
let cmd = write_command("/w/t.txt", "/tmp/x", "abc123");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
WRITE_SCRIPT.to_string(),
|
||||
"save".to_string(),
|
||||
"/w/t.txt".to_string(),
|
||||
"/tmp/x".to_string(),
|
||||
"abc123".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// P8: the size cap and base-hash checks are unit-testable in isolation from
|
||||
/// the async `write_file`.
|
||||
#[test]
|
||||
fn check_write_input_refuses_oversized_payload_and_malformed_hash() {
|
||||
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
|
||||
assert!(check_write_input(MAX_WRITE_BYTES, h).is_ok());
|
||||
assert!(check_write_input(MAX_WRITE_BYTES + 1, h).is_err());
|
||||
assert!(check_write_input(0, "not-a-hash").is_err());
|
||||
}
|
||||
|
||||
// ── M10: WRITE_SCRIPT run for real, against a temp dir on the host ──────────
|
||||
//
|
||||
// The needle test above only proves the script *contains* certain substrings; it
|
||||
// cannot catch the pipefail-shaped bug I1 was (the needle text was correct, the
|
||||
// shell semantics were not). These run the exact `sh -c SCRIPT save target tmp
|
||||
// hash` invocation `write_command` builds, so they pin the exit codes and cleanup
|
||||
// behaviour that `write_file`/`classify_write` actually depend on. `sh` and the
|
||||
// coreutils used here (`sha256sum`, `mktemp`, `dirname`, `basename`) are present
|
||||
// on dev machines and CI alike.
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_write_script(
|
||||
target: &std::path::Path,
|
||||
tmp: &std::path::Path,
|
||||
base_hash: &str,
|
||||
) -> (i32, String, String) {
|
||||
let out = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(WRITE_SCRIPT)
|
||||
.arg("save")
|
||||
.arg(target)
|
||||
.arg(tmp)
|
||||
.arg(base_hash)
|
||||
.output()
|
||||
.expect("sh must be on PATH to run this test");
|
||||
(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tc-write-{}-{}", name, uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_clean_save_replaces_the_file_and_cleans_up() {
|
||||
let dir = unique_test_dir("clean");
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
let base = sha256_hex(b"old\n");
|
||||
|
||||
let (code, stdout, stderr) = run_write_script(&target, &tmp, &base);
|
||||
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
let new_hash = sha256_hex(b"new\n");
|
||||
assert!(stdout.contains(&new_hash), "stdout={stdout}");
|
||||
// With no other writer, the reported disk hash is ours, so no conflict is shown.
|
||||
let saved = saved_file(classify_write(code as i64, &stdout, &stderr), b"new\n").unwrap();
|
||||
assert_eq!(saved, SavedFile { hash: new_hash.clone(), disk_hash: new_hash.clone() });
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new\n");
|
||||
assert!(!tmp.exists(), "the staged /tmp payload must be cleaned up");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// M2, for real: another writer lands between the script's `mv` and its final
|
||||
/// `sha256sum` (simulated by a `sha256sum` shim on PATH that rewrites the target on
|
||||
/// its second call). The save's base must still be the hash of our bytes, and the
|
||||
/// foreign hash must come back as `disk_hash`, so the editor shows "Changed on disk".
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_write_that_lands_after_ours_is_reported_not_adopted() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let real = std::process::Command::new("sh")
|
||||
.args(["-c", "command -v sha256sum"])
|
||||
.output()
|
||||
.expect("sh");
|
||||
let real = String::from_utf8_lossy(&real.stdout).trim().to_string();
|
||||
assert!(!real.is_empty(), "sha256sum must be on PATH");
|
||||
|
||||
let dir = unique_test_dir("race");
|
||||
let bin = dir.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let mark = dir.join("called-once");
|
||||
let shim = bin.join("sha256sum");
|
||||
std::fs::write(
|
||||
&shim,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ -e '{mark}' ]; then printf 'theirs\\n' > \"$2\"; fi\n: > '{mark}'\nexec '{real}' \"$@\"\n",
|
||||
mark = mark.display(),
|
||||
real = real
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
|
||||
let out = std::process::Command::new("sh")
|
||||
.env("PATH", path)
|
||||
.arg("-c")
|
||||
.arg(WRITE_SCRIPT)
|
||||
.arg("save")
|
||||
.arg(&target)
|
||||
.arg(&tmp)
|
||||
.arg(sha256_hex(b"old\n"))
|
||||
.output()
|
||||
.unwrap();
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(out.status.code(), Some(0), "stdout={stdout} stderr={stderr}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"theirs\n", "the shim's write landed last");
|
||||
|
||||
let saved = saved_file(classify_write(0, &stdout, &stderr), b"new\n").unwrap();
|
||||
assert_eq!(saved.hash, sha256_hex(b"new\n"));
|
||||
assert_eq!(saved.disk_hash, sha256_hex(b"theirs\n"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_stale_base_hash_conflicts_and_leaves_everything_untouched() {
|
||||
let dir = unique_test_dir("stale");
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
let wrong_base = sha256_hex(b"not what is on disk\n");
|
||||
|
||||
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &wrong_base);
|
||||
|
||||
assert_eq!(code, 3, "stderr={stderr}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
|
||||
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_missing_target_reports_gone() {
|
||||
let dir = unique_test_dir("gone");
|
||||
let target = dir.join("does-not-exist");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
|
||||
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"whatever"));
|
||||
|
||||
assert_eq!(code, 4, "stderr={stderr}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// I1: a real read failure must be a real error (exit 1), never the exit-3
|
||||
/// conflict a bare `sha256sum | cut` pipeline (no `pipefail` in POSIX sh) would
|
||||
/// silently produce.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_an_unreadable_target_is_an_error_not_a_conflict() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = unique_test_dir("unreadable");
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
|
||||
if std::fs::read(&target).is_ok() {
|
||||
// Running as root (or some other bypass): 0o000 does not block reads,
|
||||
// so this scenario cannot be reproduced here.
|
||||
eprintln!("skipping: still able to read a 0o000 file (root?)");
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
|
||||
|
||||
assert_eq!(
|
||||
code, 1,
|
||||
"an unreadable target must be a real error, not exit 3; stderr={stderr}"
|
||||
);
|
||||
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
|
||||
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// I3: a target the container user cannot write is refused outright, never
|
||||
/// replaced via rename.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_read_only_target_is_refused_not_replaced() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = unique_test_dir("readonly");
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o444)).unwrap();
|
||||
|
||||
if std::fs::OpenOptions::new().write(true).open(&target).is_ok() {
|
||||
eprintln!("skipping: still able to write a 0o444 file (root?)");
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
|
||||
|
||||
assert_eq!(code as i64, EXIT_READ_ONLY, "stderr={stderr}");
|
||||
assert!(stderr.contains("read-only"), "stderr={stderr}");
|
||||
assert_eq!(
|
||||
std::fs::read(&target).unwrap(),
|
||||
b"old\n",
|
||||
"a read-only file must not be replaced"
|
||||
);
|
||||
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
|
||||
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// I2: a failed stage (here: an unreadable source payload, so `cp` fails after
|
||||
/// `mktemp` has already created the destination) must not leave a partial
|
||||
/// `.<name>.triple-c-<suffix>` behind in the user's own directory.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn on_the_host_a_failed_stage_leaves_no_partial_file_behind() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = unique_test_dir("cpfail");
|
||||
let target = dir.join("t.txt");
|
||||
let tmp = dir.join("payload");
|
||||
std::fs::write(&target, b"old\n").unwrap();
|
||||
std::fs::write(&tmp, b"new\n").unwrap();
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
|
||||
if std::fs::read(&tmp).is_ok() {
|
||||
eprintln!("skipping: still able to read a 0o000 file (root?)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
|
||||
|
||||
assert_eq!(code, 1, "stderr={stderr}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with(".t.txt.triple-c-"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "staged file(s) left behind: {leftovers:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
mod auth_bridge;
|
||||
mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
mod commands;
|
||||
mod docker;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod url_open;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -28,6 +33,21 @@ pub struct AppState {
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
/// The file `preview_settings_import` last decrypted successfully, held
|
||||
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||
/// without the frontend ever passing a host path back to Rust as an
|
||||
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||
/// for why that direction specifically is the one this app treats as
|
||||
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||
/// nothing here holds a decrypted secret in memory for longer than one
|
||||
/// command's execution.
|
||||
///
|
||||
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||
/// changed underneath the pending import — otherwise confirming a
|
||||
/// preview is not actually binding on what gets applied.
|
||||
pub pending_settings_import:
|
||||
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -212,7 +232,6 @@ pub fn run() {
|
||||
let lifecycle_setup = lifecycle.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
@@ -222,7 +241,9 @@ pub fn run() {
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
lifecycle,
|
||||
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
.manage(file_viewer::registry::ViewerRegistry::default())
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
@@ -235,6 +256,48 @@ 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 — both the probe
|
||||
// containers and the probe images, the latter being the one orphan
|
||||
// the sweep can never reach on its own; 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;
|
||||
// Probe *images* too, and for a sharper reason: a probe
|
||||
// container merely pins an image the sweep then refuses to
|
||||
// touch, whereas a leftover probe image is tagged and so
|
||||
// nothing else in this app can ever collect it. See
|
||||
// `reap_probe_images`.
|
||||
crate::docker::reap_probe_images().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 +474,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,
|
||||
@@ -421,6 +483,10 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Notes
|
||||
commands::notes_commands::list_notes,
|
||||
commands::notes_commands::save_note,
|
||||
commands::notes_commands::delete_note,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
@@ -452,6 +518,7 @@ pub fn run() {
|
||||
commands::auth_token_commands::cancel_claude_token,
|
||||
commands::auth_token_commands::has_claude_token,
|
||||
commands::auth_token_commands::clear_claude_token,
|
||||
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -460,6 +527,10 @@ pub fn run() {
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
commands::settings_export_commands::preview_settings_import,
|
||||
commands::settings_export_commands::apply_settings_import,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
@@ -472,9 +543,19 @@ 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,
|
||||
// Terminal file viewer
|
||||
commands::file_viewer_commands::open_file_viewer,
|
||||
commands::file_viewer_commands::viewer_get_state,
|
||||
commands::file_viewer_commands::viewer_choose_file,
|
||||
commands::file_viewer_commands::viewer_read_file,
|
||||
commands::file_viewer_commands::viewer_poll_file,
|
||||
commands::file_viewer_commands::viewer_write_file,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
@@ -483,6 +564,9 @@ pub fn run() {
|
||||
commands::update_commands::check_image_update,
|
||||
// Help
|
||||
commands::help_commands::get_help_content,
|
||||
// Opening a link in the host browser (see `url_open` for why this
|
||||
// is not `@tauri-apps/plugin-opener` on Linux)
|
||||
url_open::open_url_external,
|
||||
// Install helper
|
||||
commands::install_helper_commands::detect_install_options,
|
||||
commands::install_helper_commands::run_docker_install,
|
||||
@@ -652,4 +736,333 @@ 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 by the same parser `build.rs` uses to
|
||||
// declare the AppManifest — so if this test can see a command, the ACL can too.
|
||||
let ordered = crate::command_census::registered_commands(include_str!("lib.rs"))
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
let registered: BTreeSet<String> = ordered.iter().cloned().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 name in &ordered {
|
||||
if seen.contains(&name.as_str()) {
|
||||
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();
|
||||
|
||||
// Plugin and core grants: the exact reviewed list, unchanged by the lockdown.
|
||||
let (bare, prefixed): (Vec<String>, Vec<String>) =
|
||||
listed.iter().cloned().partition(|g| !g.contains(':'));
|
||||
let mut sorted = prefixed;
|
||||
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",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
sorted, expected,
|
||||
"the plugin/core 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."
|
||||
);
|
||||
|
||||
// App commands: since build.rs declares the AppManifest, the bare `allow-*` grants
|
||||
// are the complete list of app commands the main window may call. `build.rs` already
|
||||
// fails the build when they disagree with generate_handler!; this keeps the reviewed
|
||||
// rule ("every non-viewer command, exactly") visible where the plugin census lives.
|
||||
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
let mut expected_bare: Vec<String> = registered
|
||||
.iter()
|
||||
.filter(|c| crate::command_census::expected_windows(c) == ["main"])
|
||||
.map(|c| crate::command_census::allow_permission(c))
|
||||
.collect();
|
||||
expected_bare.sort();
|
||||
let mut bare = bare;
|
||||
bare.sort();
|
||||
assert_eq!(
|
||||
bare, expected_bare,
|
||||
"default.json's app-command grants must be exactly the main-window commands"
|
||||
);
|
||||
assert!(bare.len() >= 100, "the census found {} app grants; the parser has stopped seeing the list", bare.len());
|
||||
|
||||
// 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `build.rs` derives the AppManifest from the handler list and this reads back what
|
||||
/// tauri-build actually embedded. `cargo test` runs the build script first, so
|
||||
/// `gen/schemas/acl-manifests.json` is fresh. This guards against the committed/generated
|
||||
/// artifact diverging from `generate_handler!` — a stale `acl-manifests.json`, or a
|
||||
/// tauri-build naming change — using the same `registered_commands` parser `build.rs` used
|
||||
/// to derive the manifest in the first place. It is *not* independent of a parser dropout on
|
||||
/// its own: if `registered_commands` lost half the list, `build.rs` would declare half a
|
||||
/// manifest and this would still compare it against the same half. That guarantee is
|
||||
/// transitive, not local — `every_command_is_registered_exactly_once` covers it, by
|
||||
/// cross-checking the parser's output against an independent `#[tauri::command]` scan, so a
|
||||
/// parser regression that silently dropped commands fails there rather than going unnoticed
|
||||
/// here.
|
||||
#[test]
|
||||
fn the_generated_app_manifest_matches_the_handler_list() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/gen/schemas/acl-manifests.json");
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.expect("gen/schemas/acl-manifests.json is written by build.rs on every build");
|
||||
let manifests: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("acl-manifests.json must parse");
|
||||
let app = manifests.get("__app-acl__").expect(
|
||||
"build.rs must declare an AppManifest — without it tauri skips the ACL for every \
|
||||
app command",
|
||||
);
|
||||
let embedded: BTreeSet<String> = app["permissions"]
|
||||
.as_object()
|
||||
.expect("the app manifest has a permissions map")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
let expected: BTreeSet<String> = registered
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
let allow = crate::command_census::allow_permission(c);
|
||||
let deny = format!("deny-{}", &allow["allow-".len()..]);
|
||||
[allow, deny]
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(registered.len() >= 100, "the parser sees {} commands", registered.len());
|
||||
assert_eq!(
|
||||
embedded, expected,
|
||||
"the embedded app manifest and generate_handler! disagree: build.rs and \
|
||||
tauri-build should have produced the same list"
|
||||
);
|
||||
assert!(
|
||||
app["permission_sets"].as_object().is_some_and(|s| s.is_empty()),
|
||||
"no permission sets: every grant is a literal allow-* string in a capability file"
|
||||
);
|
||||
assert!(app["default_permission"].is_null(), "no app `default` permission set");
|
||||
}
|
||||
|
||||
/// `build.rs`'s `check_tauri_config` (inline `app.security.capabilities`, a JSON5/TOML tauri
|
||||
/// config, `TAURI_CONFIG`) only runs inside the build script, so it only re-runs on a clean
|
||||
/// build or in CI — cargo's incremental build has no reason to notice a new
|
||||
/// `tauri.<platform>.conf.json` dropped into an already-built tree (CLAUDE.md, "Known
|
||||
/// limit"). This runs the same check, using the same `command_census` functions build.rs
|
||||
/// calls, directly against the real `app/src-tauri` directory on every `cargo test`, so that
|
||||
/// gap is closed locally too.
|
||||
#[test]
|
||||
fn the_tauri_config_capability_check_runs_against_the_real_tree() {
|
||||
let dir = env!("CARGO_MANIFEST_DIR");
|
||||
let mut problems = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).expect("readable src-tauri/") {
|
||||
let path = entry.expect("readable entry in src-tauri/").path();
|
||||
let name = path
|
||||
.file_name()
|
||||
.expect("a directory entry has a file name")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
match crate::command_census::tauri_config_file(&name) {
|
||||
None => {}
|
||||
Some(false) => problems.push(format!(
|
||||
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config \
|
||||
could declare capabilities it cannot see"
|
||||
)),
|
||||
Some(true) => {
|
||||
let json =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
problems.extend(crate::command_census::tauri_config_problem(&name, &json));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(json) = std::env::var("TAURI_CONFIG") {
|
||||
problems.extend(crate::command_census::tauri_config_problem("TAURI_CONFIG", &json));
|
||||
}
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"cargo test found what build.rs would refuse on a clean build: {problems:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,157 @@
|
||||
// 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, killing the webview and leaving a blank window — see
|
||||
/// triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// **This is not the only cause of a blank window, and the error text alone
|
||||
/// does not tell them apart.** An earlier version of this comment quoted
|
||||
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
|
||||
/// the error this fixes. The AppImage produces that same string for an
|
||||
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
|
||||
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
|
||||
/// DT_NEEDED on that library, so the EGL driver failed to load before any
|
||||
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
|
||||
/// which cost a round of debugging that started from the comment rather than
|
||||
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone — with one
|
||||
/// correction. The earlier version of this function left *any* pre-set value
|
||||
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||
/// sees any difference.
|
||||
///
|
||||
/// That matters more than it looks, because the trade described above is not
|
||||
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||
/// this comment assumed it would degrade to, not faster. See
|
||||
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||
/// whether the addon is loaded at all.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
/// call. Narrow in practice (an already-running browser just receives the
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
///
|
||||
/// That leak is now plugged rather than merely documented: `url_open` hands
|
||||
/// the opener a child environment with this variable (and the AppImage's own
|
||||
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
|
||||
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
|
||||
/// children no longer inherit it.
|
||||
#[cfg(target_os = "linux")]
|
||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||
/// already set to. Split from the mutation so it can be tested without
|
||||
/// touching process-wide environment state from a parallel test runner.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DmabufAction {
|
||||
/// Not set by the user — apply the workaround.
|
||||
Disable,
|
||||
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||
/// way to express "enabled" is for the variable not to exist.
|
||||
Remove,
|
||||
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||
LeaveAlone,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||
match current {
|
||||
None => DmabufAction::Disable,
|
||||
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||
_ => DmabufAction::LeaveAlone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
let current = std::env::var(DMABUF_VAR).ok();
|
||||
match dmabuf_action(current.as_deref()) {
|
||||
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||
DmabufAction::LeaveAlone => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::{dmabuf_action, DmabufAction};
|
||||
|
||||
#[test]
|
||||
fn unset_gets_the_workaround() {
|
||||
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||
// The bug this replaces: these all previously read as "user set it,
|
||||
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||
// it only checks presence. There was no way to ask for the GPU path.
|
||||
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::Remove,
|
||||
"{value:?} should opt out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_values_are_left_alone() {
|
||||
for value in ["1", "true", "yes", "anything"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::LeaveAlone,
|
||||
"{value:?} should be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||
// environment this app was started with, and the workaround below is one
|
||||
// of the things that must not leak into it (see triple-c#34). Anything
|
||||
// added here that mutates the environment belongs after this line.
|
||||
triple_c_lib::url_open::capture_pristine_environment();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||
///
|
||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||
/// The addon therefore loads successfully and then renders every frame on
|
||||
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||
/// disabled on Linux.
|
||||
///
|
||||
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||
/// the zero value has to mean "we choose", not "off", or every existing
|
||||
/// settings file would silently pin the answer at whatever the default was
|
||||
/// the day it was written.
|
||||
#[serde(default)]
|
||||
pub terminal_gpu_rendering: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
pub mod project;
|
||||
pub mod settings_export;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
pub use settings_export::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||
/// session.
|
||||
///
|
||||
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||
/// classification decision at writing time is one the user is least willing to
|
||||
/// make, and it would turn one pane into two features.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Pinned notes sort first, then by `updated_at` descending.
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(title: String, body: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title,
|
||||
body,
|
||||
pinned: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -38,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `auth_bridge_enabled` defaults to **on**, and the default is what makes
|
||||
/// `claude login` work at all.
|
||||
///
|
||||
/// The login flow binds a *random* ephemeral loopback port inside the
|
||||
/// container and then sends the host's browser to `127.0.0.1:<that port>`.
|
||||
/// On the host nothing is listening there, so the callback lands on a closed
|
||||
/// port and the CLI waits for a redirect that can never arrive. The bridge
|
||||
/// mirrors the container's loopback listeners onto the same host port, which
|
||||
/// is the only thing that closes that loop — so off-by-default made a hang the
|
||||
/// out-of-the-box experience.
|
||||
///
|
||||
/// Returning `true` from a `#[serde(default)]` helper (rather than flipping the
|
||||
/// constructor alone) is deliberate: existing `projects.json` records were
|
||||
/// written before this field existed, or while it was off, and an absent key is
|
||||
/// what the default is read for. A project that wants the old behaviour turns
|
||||
/// the toggle off, which persists an explicit `false`.
|
||||
fn default_auth_bridge_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How much autonomy Claude Code is granted inside the container.
|
||||
///
|
||||
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||
@@ -85,31 +199,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)]
|
||||
@@ -132,19 +356,48 @@ pub struct Project {
|
||||
pub sandbox_mode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// The auth bridge: while the container runs, its loopback listeners are
|
||||
/// mirrored onto the host's loopback so browser OAuth callbacks
|
||||
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// Purely host-side — it deliberately has no container-recreation label,
|
||||
/// because toggling it changes nothing about the container itself.
|
||||
#[serde(default)]
|
||||
///
|
||||
/// **On by default**, and opt-*out* rather than opt-in — see
|
||||
/// [`default_auth_bridge_enabled`] for why the default is the feature.
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
pub auth_bridge_enabled: bool,
|
||||
/// Opt in to the browser-view pane, which watches and takes over the
|
||||
/// browser Claude drives with Playwright inside the container. Purely
|
||||
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||
/// container-recreation label.
|
||||
///
|
||||
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
|
||||
/// rather than keeping its own copy, so the pane comes back the way it was
|
||||
/// left. Off by default, and unlike the auth bridge it stays that way — a
|
||||
/// view costs a container exec, a Node daemon and a host port, and a
|
||||
/// container without Playwright cannot serve one at all.
|
||||
///
|
||||
/// Durable does **not** mean auto-started: nothing brings a viewer up on
|
||||
/// app start, so a project left enabled reports `enabled` with a state of
|
||||
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
|
||||
#[serde(default)]
|
||||
pub browser_view_enabled: bool,
|
||||
/// Grant the container what a VPN client needs to build a tunnel:
|
||||
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
|
||||
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
|
||||
/// OpenVPN) installs and runs but its connection attempt hangs until it
|
||||
/// times out, because it cannot create the tunnel interface or touch the
|
||||
/// routing table.
|
||||
///
|
||||
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
|
||||
/// container reconfigure its own network stack, which reaches further than
|
||||
/// it sounds — see `vpn_host_config` for what it does and does not confer.
|
||||
/// Unlike `auth_bridge_enabled` this *is*
|
||||
/// container state, so it carries a `triple-c.vpn-support` label and is
|
||||
/// compared in `container_needs_recreation` — capabilities and devices are
|
||||
/// fixed at creation and can only change by recreating the container.
|
||||
#[serde(default)]
|
||||
pub vpn_support_enabled: bool,
|
||||
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||
@@ -202,6 +455,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
|
||||
@@ -364,8 +672,9 @@ impl Project {
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: false,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
auth_bridge_enabled: default_auth_bridge_enabled(),
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: default_use_shared_auth_token(),
|
||||
full_permissions: false,
|
||||
permission_mode: None,
|
||||
@@ -424,3 +733,254 @@ 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);
|
||||
}
|
||||
|
||||
// ── The host-side per-project toggles ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
|
||||
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
|
||||
// record written by a shipped binary and has no `auth_bridge_enabled`
|
||||
// key at all. Without this, every existing project keeps hanging on
|
||||
// `claude login` until its owner finds the toggle.
|
||||
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
assert!(project.auth_bridge_enabled);
|
||||
|
||||
// The browser view is the other way round and must stay so: it costs a
|
||||
// Node daemon, a container exec loop and a host port, and most
|
||||
// containers have no Playwright to serve it with.
|
||||
assert!(!project.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_auth_bridge_off_survives_the_default() {
|
||||
// Opt-out has to be expressible, or the toggle does nothing across a
|
||||
// restart. An explicit `false` in the file beats the default.
|
||||
let json = r#"{ "auth_bridge_enabled": false }"#;
|
||||
#[derive(Deserialize)]
|
||||
struct JustTheFlag {
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
auth_bridge_enabled: bool,
|
||||
}
|
||||
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
|
||||
assert!(!parsed.auth_bridge_enabled);
|
||||
|
||||
// And a saved project always writes the key, so the choice is pinned
|
||||
// rather than re-defaulted on the next load.
|
||||
let mut p = Project::new("demo".to_string(), Vec::new());
|
||||
p.auth_bridge_enabled = false;
|
||||
let round_tripped: Project =
|
||||
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert!(!round_tripped.auth_bridge_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
assert!(p.auth_bridge_enabled);
|
||||
assert!(!p.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
|
||||
// `ProjectsStore::new` runs every record through this before
|
||||
// deserialising. If it inserted either key — even as `false` — the
|
||||
// serde default above would never be consulted for an existing project
|
||||
// and this change would be a no-op on exactly the projects it is for.
|
||||
let legacy = serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"path": "/home/u/demo",
|
||||
});
|
||||
let migrated = Project::migrate_from_value(legacy);
|
||||
let obj = migrated.as_object().unwrap();
|
||||
assert!(obj.contains_key("paths"), "the migration should still do its own job");
|
||||
assert!(!obj.contains_key("auth_bridge_enabled"));
|
||||
assert!(!obj.contains_key("browser_view_enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
||||
//! and after decryption (see `storage::settings_crypto`). It bundles
|
||||
//! `AppSettings` — with one field carved out, see below — with the global
|
||||
//! secrets that live in the OS keychain instead: the shared Claude Code
|
||||
//! OAuth login and the model gateway's two keys. Per-project settings,
|
||||
//! per-project secrets, and anything living in a project's Docker volumes
|
||||
//! are deliberately out of scope: this exports the *host* environment, not
|
||||
//! any one project's.
|
||||
//!
|
||||
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
||||
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
||||
//! server that binds every interface, stored as a plain field on the
|
||||
//! struct that is otherwise safe to treat as config. A review of this
|
||||
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
||||
//! that token along as if it were as inert as a port number, and — worse —
|
||||
//! importing it would apply `web_terminal.enabled` and the token together
|
||||
//! with no more warning than any other setting, letting a crafted export
|
||||
//! silently stand up a LAN-listening terminal server with an
|
||||
//! attacker-known token on the next launch. `export_settings` /
|
||||
//! `apply_settings_import` blank this field out of the `settings` they
|
||||
//! read from and write to, and it travels only through
|
||||
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
||||
//! "only overwrite what the import actually has" treatment as the other
|
||||
//! three secrets.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AppSettings, ImageSource};
|
||||
|
||||
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
||||
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
||||
/// field is ever removed or its meaning changes. `apply_settings_import`
|
||||
/// checks this before touching anything.
|
||||
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// The global secrets bundled into an export. Deliberately a separate struct
|
||||
/// from `AppSettings`: these live in the OS keychain, never in
|
||||
/// `settings.json`, and — outside of this export/import flow — the values
|
||||
/// themselves never cross into the frontend; see the doc comments on
|
||||
/// `storage::secure::get_gateway_api_key` and
|
||||
/// `commands::settings_export_commands` for why that boundary matters here
|
||||
/// too.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExportedSecrets {
|
||||
#[serde(default)]
|
||||
pub claude_oauth_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_master_key: Option<String>,
|
||||
/// See the module doc comment — this is `AppSettings::web_terminal
|
||||
/// .access_token`, carved out because it is a live bearer credential,
|
||||
/// not config, despite living on a struct that is otherwise safe to
|
||||
/// export wholesale.
|
||||
#[serde(default)]
|
||||
pub web_terminal_access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportedSecrets {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
||||
blank(&self.claude_oauth_token)
|
||||
&& blank(&self.gateway_api_key)
|
||||
&& blank(&self.gateway_master_key)
|
||||
&& blank(&self.web_terminal_access_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `apply_settings_import` hands back: the settings that were actually
|
||||
/// saved, plus a human-readable note for each keychain secret this import
|
||||
/// carried but could not be restored. A keychain write failing partway
|
||||
/// through must not read as unqualified success just because the settings
|
||||
/// half of the import went through.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportOutcome {
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secret_restore_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The full plaintext payload — this is what gets encrypted on export and
|
||||
/// what decryption recovers on import. Never written to disk unencrypted;
|
||||
/// see `storage::settings_crypto`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsExportPayload {
|
||||
pub format_version: u32,
|
||||
/// RFC3339. Purely informational — shown in the import preview so a user
|
||||
/// picking between a few old export files has something to go on.
|
||||
pub exported_at: String,
|
||||
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
||||
/// field below already round-trips through `#[serde(default)]`-covered
|
||||
/// `AppSettings`, so an older or newer export still deserializes; this is
|
||||
/// for a human to notice "this is from a much older version" if an import
|
||||
/// ever looks wrong, not something the code branches on.
|
||||
pub app_version: String,
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secrets: ExportedSecrets,
|
||||
}
|
||||
|
||||
/// What `preview_settings_import` hands the frontend before anything is
|
||||
/// applied — counts and presence flags only, **never** a secret value itself,
|
||||
/// so this type is safe to return across the IPC boundary and render
|
||||
/// directly. The confirmation UI is built from this.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportPreview {
|
||||
pub exported_at: String,
|
||||
pub app_version: String,
|
||||
pub custom_env_var_count: usize,
|
||||
pub gateway_model_count: usize,
|
||||
pub has_claude_code_settings: bool,
|
||||
pub has_claude_oauth_token: bool,
|
||||
pub has_gateway_api_key: bool,
|
||||
pub has_gateway_master_key: bool,
|
||||
pub has_web_terminal_access_token: bool,
|
||||
/// Whether the imported settings turn the web terminal on. Named
|
||||
/// separately from the token above: `enabled` and the token are two
|
||||
/// different fields, either can be true without the other, and
|
||||
/// "this import turns on a service that listens on your network" is
|
||||
/// exactly the kind of change a wholesale settings replace must not
|
||||
/// bury in a generic "settings replaced" line — see the module doc
|
||||
/// comment on why this field exists at all.
|
||||
pub enables_web_terminal: bool,
|
||||
/// Non-blank custom base URLs the import would set, so a redirect of
|
||||
/// model traffic to somewhere other than the usual provider is visible
|
||||
/// at import time rather than discovered later. These are endpoints, not
|
||||
/// secrets — safe to show verbatim, unlike everything above.
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub llamacpp_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_base: Option<String>,
|
||||
/// Whether the import sets a custom Docker image, and its name if so —
|
||||
/// disclosed for the same reason as the base URLs above, and arguably
|
||||
/// more sharply: this is the image *every* project container is created
|
||||
/// from (`models::container_config::resolve_image_name`), so a crafted
|
||||
/// export pointing it at an attacker-controlled image is a path to
|
||||
/// running arbitrary code with whatever a project's containers are
|
||||
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
||||
/// not merely a redirected API endpoint.
|
||||
#[serde(default)]
|
||||
pub image_source: ImageSource,
|
||||
#[serde(default)]
|
||||
pub custom_image_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
/// into a preview a user reads and a UI renders without truncation of its
|
||||
/// own. Applied to every field above that carries free-form text straight
|
||||
/// from the import file rather than a count or a boolean — a base URL or an
|
||||
/// image name a hostile export author controls has had no validation done
|
||||
/// on it yet at preview time, and nothing stops it from being pathological
|
||||
/// (embedded control characters, or long enough to blow out the confirmation
|
||||
/// dialog and push the security warnings below it off screen).
|
||||
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
||||
|
||||
fn sanitize_for_preview(value: &str) -> String {
|
||||
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
||||
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsImportPreview {
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
.map(sanitize_for_preview)
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
Self {
|
||||
exported_at: payload.exported_at.clone(),
|
||||
app_version: payload.app_version.clone(),
|
||||
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
||||
gateway_model_count: payload.settings.gateway.models.len(),
|
||||
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
||||
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
||||
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
||||
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
||||
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
||||
enables_web_terminal: payload.settings.web_terminal.enabled,
|
||||
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
||||
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
||||
openai_compatible_base_url: sanitized_non_blank(
|
||||
&payload.settings.global_openai_compatible.base_url,
|
||||
),
|
||||
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
||||
image_source: payload.settings.image_source.clone(),
|
||||
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::AppSettings;
|
||||
|
||||
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
||||
let settings = AppSettings {
|
||||
global_custom_env_vars: vec![
|
||||
crate::models::EnvVar {
|
||||
key: "A".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::models::EnvVar {
|
||||
key: "B".to_string(),
|
||||
value: "2".to_string(),
|
||||
},
|
||||
],
|
||||
..AppSettings::default()
|
||||
};
|
||||
SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_never_carries_a_secret_value() {
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
||||
gateway_api_key: Some("sk-another-secret".to_string()),
|
||||
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
||||
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let serialized = serde_json::to_string(&preview).unwrap();
|
||||
|
||||
assert!(!serialized.contains("sk-super-secret-token"));
|
||||
assert!(!serialized.contains("sk-another-secret"));
|
||||
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
||||
assert!(!serialized.contains("wt-super-secret-token"));
|
||||
assert!(preview.has_claude_oauth_token);
|
||||
assert!(preview.has_gateway_api_key);
|
||||
assert!(preview.has_gateway_master_key);
|
||||
assert!(preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
||||
// A keychain entry that exists but holds only whitespace must not
|
||||
// read as "present" — same "blank counts as absent" rule the
|
||||
// keychain layer itself applies when storing these.
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
gateway_api_key: None,
|
||||
gateway_master_key: None,
|
||||
web_terminal_access_token: Some(" ".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(!preview.has_claude_oauth_token);
|
||||
assert!(!preview.has_gateway_api_key);
|
||||
assert!(!preview.has_gateway_master_key);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
||||
// `enabled` and the token are independent fields — a crafted export
|
||||
// could set one without the other, and both are worth a user's
|
||||
// attention: this is the field that exists specifically so "this
|
||||
// import turns on a service that listens on your network" cannot
|
||||
// hide inside a generic "settings replaced" summary.
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.web_terminal.enabled = true;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(preview.enables_web_terminal);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
||||
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
||||
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(
|
||||
preview.ollama_base_url.as_deref(),
|
||||
Some("http://attacker.example:11434")
|
||||
);
|
||||
assert_eq!(preview.llamacpp_base_url, None);
|
||||
assert_eq!(preview.openai_compatible_base_url, None);
|
||||
assert_eq!(
|
||||
preview.gateway_api_base.as_deref(),
|
||||
Some("https://gateway.example/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_reflect_the_real_settings() {
|
||||
let payload = payload_with(ExportedSecrets::default());
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.custom_env_var_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
||||
assert!(ExportedSecrets::default().is_empty());
|
||||
assert!(!ExportedSecrets {
|
||||
claude_oauth_token: Some("x".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
||||
// Matches the "blank counts as absent" rule every other consumer of
|
||||
// these fields applies (`has_claude_oauth_token` and friends above) —
|
||||
// a keychain entry that exists but holds only whitespace carries
|
||||
// nothing usable, so the export-time "nothing to export" log line
|
||||
// must still fire for it.
|
||||
assert!(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_docker_image_is_surfaced() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.image_source = crate::models::ImageSource::Custom;
|
||||
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
||||
assert_eq!(
|
||||
preview.custom_image_name.as_deref(),
|
||||
Some("ghcr.io/attacker/triple-c:latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url =
|
||||
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let shown = preview.ollama_base_url.expect("non-blank base url");
|
||||
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
||||
// +1 for the trailing ellipsis appended when truncated.
|
||||
assert!(
|
||||
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
||||
"preview string was not capped: {} chars",
|
||||
shown.chars().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Per-project mutual exclusion for everything that rewrites a project's
|
||||
//! container or its snapshot image.
|
||||
//!
|
||||
//! ## Why polling was not enough
|
||||
//!
|
||||
//! Until this module existed the app had exactly one mutual-exclusion
|
||||
//! primitive — the `ACTIVE_MIGRATIONS` set behind
|
||||
//! `migration_commands::is_migrating` — and it was **one-way**. A migration
|
||||
//! took a guard for its whole run; everything else merely *asked once, at
|
||||
//! entry*, whether a migration was in flight and then proceeded with no claim
|
||||
//! of its own. Two non-migration operations on the same project could not see
|
||||
//! each other at all, and a migration could start underneath one that was
|
||||
//! already halfway through.
|
||||
//!
|
||||
//! That is not a theoretical gap. Compaction resolves
|
||||
//! `triple-c-snapshot-{id}:latest` when its build starts and commits back over
|
||||
//! that same tag minutes later, and the Settings panel is a sidebar rather than
|
||||
//! a modal — so Project Home stays live with Start, Stop, Reset and Migrate all
|
||||
//! clickable while a compaction runs. Three interleavings were reproduced:
|
||||
//!
|
||||
//! * Compaction commits `flat(A)` over `:latest` after a migration has already
|
||||
//! moved that tag to a new lineage. The migration is silently reverted, the
|
||||
//! config replay lands twice, and the migration record says
|
||||
//! `awaiting-confirmation` against a base the tag no longer points at.
|
||||
//! * Compaction resolves A, the user starts the project and works for an hour,
|
||||
//! a recreate commits D over `:latest`, and the compaction then overwrites it
|
||||
//! with `flat(A)` — orphaning an hour of system-layer work while reporting
|
||||
//! success and a byte saving.
|
||||
//! * Compaction resurrects the system layer a Reset had just destroyed.
|
||||
//!
|
||||
//! Every one of those is "two writers of `:latest`, neither holding anything".
|
||||
//! So this registry replaces the polling with an actual claim: an operation
|
||||
//! **acquires** a [`ProjectGuard`] and holds it for its whole run, and a second
|
||||
//! operation on the same project is refused with a message naming the holder.
|
||||
//!
|
||||
//! ## What this does NOT protect against, stated plainly
|
||||
//!
|
||||
//! **This is in-process state.** Two copies of the app pointed at the same
|
||||
//! Docker daemon share nothing here: instance A's compaction and instance B's
|
||||
//! migration will both acquire happily and then race exactly as before.
|
||||
//! `reap_probe_containers` and the `triple-c-compact-*` / `triple-c-scrub-*`
|
||||
//! sweeps are worse than that — they are daemon-wide force-removals driven by
|
||||
//! a name or a label, so instance B can destroy a container instance A is
|
||||
//! mid-commit against.
|
||||
//!
|
||||
//! A daemon-visible lock was considered and rejected for now, and the reasoning
|
||||
//! is recorded here so it is not re-derived from scratch:
|
||||
//!
|
||||
//! * A **lock container** would work — container names are unique daemon-wide
|
||||
//! and `create` fails atomically on a name conflict — but a container has to
|
||||
//! be created *from an image*, and that pins the image. A lock on
|
||||
//! `triple-c-snapshot-{id}` would block the very `rmi`/sweep paths it guards,
|
||||
//! and a leaked lock container would pin multiple gigabytes forever.
|
||||
//! * A **named volume** is not usable: `create_volume` on an existing name
|
||||
//! returns the existing volume rather than failing, so it cannot be a
|
||||
//! test-and-set.
|
||||
//! * A **label on the snapshot image** is not atomic — read/modify/commit has
|
||||
//! the same race it would be trying to close.
|
||||
//!
|
||||
//! So the cross-process case is **documented, not solved**. What this module
|
||||
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
|
||||
//! reapers skip work while this process is mid-operation, and the reapers
|
||||
//! themselves gained age gates so a young container belonging to somebody else
|
||||
//! is left alone (see `docker::migration::reap_probe_containers`).
|
||||
//!
|
||||
//! ## Refuse, do not queue
|
||||
//!
|
||||
//! [`try_acquire`] never waits. Every caller is a user-initiated action behind
|
||||
//! a button, and a button that blocks for the four minutes a compaction takes
|
||||
//! is worse than one that says what is running. The refusal string is written
|
||||
//! for the user and names the holder.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// The operations that claim a project.
|
||||
///
|
||||
/// One variant per *class of writer*, not per command: `Recreate` covers Start
|
||||
/// as well, because Start's create-and-commit path is the same writer of
|
||||
/// `triple-c-snapshot-{id}:latest` that a recreate is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProjectOp {
|
||||
/// `migrate_project_to_base`, `resume_migration`, `rollback_migration`,
|
||||
/// `confirm_migration`.
|
||||
Migration,
|
||||
/// `disk::compact_snapshot` — the long one, and the reason this exists.
|
||||
///
|
||||
/// Not constructed on this branch: the Disk panel and its compaction were
|
||||
/// held back for separate hardening and live on `hold/disk-and-dragout`.
|
||||
/// The variant stays because this registry is the thing that made those
|
||||
/// operations safe to re-land, and a re-land that had to re-derive the
|
||||
/// claim classes would be re-deriving the bug.
|
||||
#[allow(dead_code)]
|
||||
Compaction,
|
||||
/// Start / stop / recreate. Anything in `start_project_container`'s path.
|
||||
Recreate,
|
||||
/// `rebuild_project_container` — deletes both volumes and the snapshot.
|
||||
Reset,
|
||||
/// `disk::destroy` — a volume, a snapshot image, or a rollback pin.
|
||||
Destroy,
|
||||
/// `disk::clear_caches` — an exec into the live container. It does not
|
||||
/// write `:latest`, but it must not run while the container is being
|
||||
/// removed out from under it.
|
||||
///
|
||||
/// Not constructed on this branch, for the same reason as
|
||||
/// [`ProjectOp::Compaction`].
|
||||
#[allow(dead_code)]
|
||||
CacheClear,
|
||||
/// `container::scrub_secrets_from_snapshots` — the third writer of
|
||||
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
|
||||
/// creates a scratch container from the snapshot and commits back over the
|
||||
/// same tag, so it is the same read-modify-write shape as a compaction and
|
||||
/// loses the same race: any `:latest` move landing between its create and
|
||||
/// its commit is overwritten by an image derived from the pre-read state.
|
||||
SecretScrub,
|
||||
}
|
||||
|
||||
impl ProjectOp {
|
||||
/// What is happening, phrased for the message a user reads.
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "A container base update is running for this project",
|
||||
ProjectOp::Compaction => "This project's snapshot is being compacted",
|
||||
ProjectOp::Recreate => "This project's container is being started or recreated",
|
||||
ProjectOp::Reset => "This project is being reset",
|
||||
ProjectOp::Destroy => "Something of this project's is being deleted",
|
||||
ProjectOp::CacheClear => "This project's caches are being cleared",
|
||||
ProjectOp::SecretScrub => "A revoked credential is being removed from this project's snapshot",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the *refused* caller was trying to do, for the tail of the message.
|
||||
fn blocked_action(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "starting a base update",
|
||||
ProjectOp::Compaction => "compacting its snapshot",
|
||||
ProjectOp::Recreate => "starting or recreating its container",
|
||||
ProjectOp::Reset => "resetting it",
|
||||
ProjectOp::Destroy => "deleting anything of its",
|
||||
ProjectOp::CacheClear => "clearing its caches",
|
||||
ProjectOp::SecretScrub => "removing a credential from its snapshot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project id → the operation currently holding it.
|
||||
///
|
||||
/// A `std::sync::Mutex` rather than a `tokio` one on purpose: it is only ever
|
||||
/// held for the length of a `HashMap` insert or remove, never across an await,
|
||||
/// and [`is_held_by`] has to be callable from the synchronous helpers in
|
||||
/// `disk.rs` that already ask this question.
|
||||
static HOLDERS: OnceLock<Mutex<HashMap<String, ProjectOp>>> = OnceLock::new();
|
||||
|
||||
fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
|
||||
HOLDERS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A claim on one project, released on drop.
|
||||
///
|
||||
/// RAII rather than an explicit release for the reason [`ProjectOp::Migration`]'s
|
||||
/// predecessor already learned: a plain release statement is skipped by an
|
||||
/// early `?`, by a panic, and by the future simply being dropped. A guard is
|
||||
/// not.
|
||||
/// Dropping this releases the claim, so a caller that discards it has taken no
|
||||
/// lock at all — `let _ = try_acquire(...)` drops immediately and reads as
|
||||
/// success. `#[must_use]` makes that a compile warning rather than a race.
|
||||
#[must_use = "the claim is released as soon as this guard is dropped; bind it for the whole operation"]
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGuard {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ProjectGuard {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` on a poisoned lock: a panic while some other thread held
|
||||
// this map for the duration of one insert cannot have left it
|
||||
// inconsistent, and refusing to release afterwards would strand the
|
||||
// project as permanently busy.
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim a project for `op`, or say who has it.
|
||||
///
|
||||
/// The error is user-facing copy, not a debug string — it goes straight back
|
||||
/// over IPC to a toast.
|
||||
pub fn try_acquire(project_id: &str, op: ProjectOp) -> Result<ProjectGuard, String> {
|
||||
let mut map = holders().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(holder) = map.get(project_id).copied() {
|
||||
return Err(format!(
|
||||
"{}. Wait for it to finish before {}.",
|
||||
holder.describe(),
|
||||
op.blocked_action()
|
||||
));
|
||||
}
|
||||
map.insert(project_id.to_string(), op);
|
||||
Ok(ProjectGuard {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which operation holds this project, if any.
|
||||
pub fn held(project_id: &str) -> Option<ProjectOp> {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(project_id)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Whether this project is held by exactly `op`.
|
||||
///
|
||||
/// `migration_commands::is_migrating` is this, specialised — which is the whole
|
||||
/// point of folding `ACTIVE_MIGRATIONS` into this registry: there is now one
|
||||
/// answer to "is something happening to this project", not two that can
|
||||
/// disagree.
|
||||
pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
|
||||
held(project_id) == Some(op)
|
||||
}
|
||||
|
||||
/// Whether any project **other than** `exclude_project_id` is currently held by
|
||||
/// `op`. Pass an empty id to ask about every project.
|
||||
///
|
||||
/// Used by the daemon-wide reapers, which cannot tell which project a
|
||||
/// `triple-c-compact-*` container belongs to — the name carries a random uuid,
|
||||
/// not a project id — so "is this process compacting anything right now" is the
|
||||
/// only in-process question they can ask before force-removing one. The
|
||||
/// exclusion is for the reaper that runs *inside* a compaction, which is
|
||||
/// already holding a claim of its own and would otherwise see it and skip.
|
||||
///
|
||||
/// No production caller on this branch: the compaction reaper it was written
|
||||
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
|
||||
/// (and still tested) because it is the only bound this module offers on the
|
||||
/// cross-process case documented above.
|
||||
#[allow(dead_code)]
|
||||
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.any(|(project_id, held)| *held == op && project_id != exclude_project_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are namespaced per test: the registry is process-global, and
|
||||
/// `cargo test` runs these on several threads at once.
|
||||
fn id(name: &str) -> String {
|
||||
format!("project-lock-test-{}", name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_acquire_on_the_same_project_is_refused() {
|
||||
let p = id("second-acquire");
|
||||
let first = try_acquire(&p, ProjectOp::Compaction).expect("first claim");
|
||||
let second = try_acquire(&p, ProjectOp::Recreate);
|
||||
let err = second.expect_err("a second claim must be refused, not queued");
|
||||
// The refusal has to name the holder — "busy" alone leaves the user
|
||||
// with nothing to wait for.
|
||||
assert!(err.contains("snapshot is being compacted"), "{}", err);
|
||||
assert!(err.contains("starting or recreating"), "{}", err);
|
||||
drop(first);
|
||||
// And it has to be retakeable the moment the holder goes away. Bound
|
||||
// rather than discarded: `#[must_use]` is what stops a real caller
|
||||
// writing `try_acquire(...)` and believing it holds something.
|
||||
let retaken = try_acquire(&p, ProjectOp::Recreate).expect("released on drop");
|
||||
drop(retaken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_an_early_return() {
|
||||
let p = id("early-return");
|
||||
fn bails(project_id: &str) -> Result<(), String> {
|
||||
let _guard = try_acquire(project_id, ProjectOp::Reset)?;
|
||||
Err("something failed".to_string())
|
||||
}
|
||||
assert!(bails(&p).is_err());
|
||||
assert_eq!(held(&p), None, "an early `?` must not strand the claim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_a_panic() {
|
||||
let p = id("panic");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let _guard = try_acquire(&id("panic"), ProjectOp::Migration).unwrap();
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(held(&p), None, "a panic must not strand the claim either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_projects_do_not_block_each_other() {
|
||||
let a = id("independent-a");
|
||||
let b = id("independent-b");
|
||||
let _one = try_acquire(&a, ProjectOp::Compaction).expect("a");
|
||||
let _two = try_acquire(&b, ProjectOp::Compaction).expect("b");
|
||||
assert!(is_held_by(&a, ProjectOp::Compaction));
|
||||
assert!(is_held_by(&b, ProjectOp::Compaction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_held_by_distinguishes_the_operation() {
|
||||
let p = id("which-op");
|
||||
let _guard = try_acquire(&p, ProjectOp::Compaction).unwrap();
|
||||
assert!(is_held_by(&p, ProjectOp::Compaction));
|
||||
assert!(
|
||||
!is_held_by(&p, ProjectOp::Migration),
|
||||
"a compaction is not a migration — `is_migrating` is built on this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_held_sees_across_projects() {
|
||||
let p = id("any-held");
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, ""));
|
||||
let _guard = try_acquire(&p, ProjectOp::Destroy).unwrap();
|
||||
assert!(any_held_excluding(ProjectOp::Destroy, ""));
|
||||
// …and a holder can ask the question without its own claim answering
|
||||
// it, which is what lets a compaction sweep leftovers before it starts.
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, &p));
|
||||
}
|
||||
|
||||
/// Concurrency, not just sequencing: N threads racing for one project must
|
||||
/// produce exactly one winner.
|
||||
#[test]
|
||||
fn exactly_one_of_many_racing_threads_wins() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let p = id("race");
|
||||
let start = Arc::new(std::sync::Barrier::new(8));
|
||||
// The second barrier is what makes this deterministic rather than
|
||||
// merely likely: no winner releases until every thread has had its
|
||||
// turn, so "only one got in" cannot be an artefact of a loser arriving
|
||||
// after the winner already left.
|
||||
let attempted = Arc::new(std::sync::Barrier::new(8));
|
||||
let won = Arc::new(AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let start = Arc::clone(&start);
|
||||
let attempted = Arc::clone(&attempted);
|
||||
let won = Arc::clone(&won);
|
||||
let p = p.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
start.wait();
|
||||
let claim = try_acquire(&p, ProjectOp::Compaction);
|
||||
if claim.is_ok() {
|
||||
won.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
attempted.wait();
|
||||
drop(claim);
|
||||
}));
|
||||
}
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
won.load(Ordering::SeqCst),
|
||||
1,
|
||||
"eight threads raced for one project and more than one got in"
|
||||
);
|
||||
assert_eq!(held(&p), None);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,29 @@ fn sanitize(project_id: &str) -> String {
|
||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||
/// blocking every future migration on a corrupt record.
|
||||
///
|
||||
/// **A corrupt record is copied aside and left in place.** An earlier version
|
||||
/// *renamed* it to `.bak`, on the reasoning that a file nothing can parse
|
||||
/// should stop making the project look busy. That destroyed the one signal
|
||||
/// [`has_record`] exists to carry. The chain, in order:
|
||||
///
|
||||
/// 1. The rename makes the file vanish, so `has_record` — pure filesystem
|
||||
/// presence — flips to false.
|
||||
/// 2. `reconcile_migration` calls this, gets `Ok(None)`, and returns. An
|
||||
/// in-flight or interrupted migration becomes invisible: no resume offer, no
|
||||
/// rollback offer, and the phase is never normalised.
|
||||
/// 3. Both pin reapers use `has_record` as their conservative guard, so the
|
||||
/// project's `:pre-migration-*` tag — the only copy of its pre-migration
|
||||
/// system layer — is now "ownerless" to both of them, and the startup sweep
|
||||
/// turns the untag into a deletion.
|
||||
///
|
||||
/// A record that cannot be parsed is exactly the case where the *most*
|
||||
/// conservative answer is wanted, not the least. So the bytes are copied to a
|
||||
/// **uniquely named** backup (a fixed `.bak` meant a second corruption silently
|
||||
/// overwrote the first, and nothing ever read either back) and the original
|
||||
/// stays where it is. The pin it describes then ages out through the ownerless
|
||||
/// tombstone in `docker::migration::reap_stale_migration_pins` rather than
|
||||
/// being reaped on the next app start.
|
||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
let path = state_path(project_id)?;
|
||||
if !path.exists() {
|
||||
@@ -59,27 +82,326 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
match serde_json::from_str::<MigrationState>(&data) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => {
|
||||
// Three outcomes, and they must not be conflated: a copy was made,
|
||||
// a copy was deliberately not made, or a copy failed. The previous
|
||||
// version folded "already kept enough" into `Ok(())` and then told
|
||||
// the user "a copy was kept at <path>" — naming a file that was
|
||||
// never created. A message that invents a backup is worse than no
|
||||
// message, because it is what someone reads before going to look
|
||||
// for their data.
|
||||
let backup = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
let kept = if backup.exists() {
|
||||
Kept::AlreadyThere
|
||||
} else if corrupt_backups_full(&path) {
|
||||
Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)
|
||||
} else {
|
||||
match fs::copy(&path, &backup) {
|
||||
Ok(_) => Kept::Copied,
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
};
|
||||
log::error!(
|
||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||
"Failed to parse migration state for project {}: {} — treating as absent, but \
|
||||
the record is left in place so `has_record` still protects its rollback pin{}",
|
||||
project_id,
|
||||
e
|
||||
e,
|
||||
match kept {
|
||||
Kept::Copied | Kept::AlreadyThere =>
|
||||
format!(" (a copy is at {})", backup.display()),
|
||||
// The earliest copies are the ones worth having, so the cap
|
||||
// keeps those and drops this one. Say so, rather than
|
||||
// implying a file exists.
|
||||
Kept::EnoughAlready(n) => format!(
|
||||
" (no copy kept — {} earlier copies of this record are already saved \
|
||||
alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(ref e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write a project's migration state.
|
||||
/// What [`load`] did about a copy of an unparseable record, so the log line can
|
||||
/// tell the truth about whether a file exists.
|
||||
enum Kept {
|
||||
Copied,
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere,
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Where a copy of an unparseable record is kept.
|
||||
///
|
||||
/// Timestamped rather than a fixed `.bak`: a second corruption used to
|
||||
/// overwrite the first, so the one case where the user's bytes matter most was
|
||||
/// the case where they were most likely to be gone.
|
||||
fn corrupt_backup_path(path: &std::path::Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt record are kept.
|
||||
///
|
||||
/// Timestamping fixed the "second corruption overwrote the first" bug and
|
||||
/// introduced its opposite: [`load`] runs on every reconcile, every survey and
|
||||
/// every reaper pass, so a record that is *persistently* unparseable — the
|
||||
/// normal case, since nothing repairs it — mints a new copy every time the
|
||||
/// clock's second changes. Nothing ever reads them back and nothing ever
|
||||
/// removed them.
|
||||
///
|
||||
/// Four is enough for the only use there is: a human looking at what the file
|
||||
/// held. See [`corrupt_backups_full`] for why the cap is applied before the
|
||||
/// copy rather than by pruning after it.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this record already exist.
|
||||
///
|
||||
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||
/// implemented by writing a file and deleting it again on every pass — and so
|
||||
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||
/// whatever produced the corruption.
|
||||
///
|
||||
/// A directory that cannot be listed answers "not full": failing open here
|
||||
/// costs at most one extra file, and failing closed would drop the very first
|
||||
/// copy of a record nothing else has kept.
|
||||
fn corrupt_backups_full(path: &std::path::Path) -> bool {
|
||||
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||
return false;
|
||||
};
|
||||
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||
// this can never match another project's copies or an unrelated `.bak`.
|
||||
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||
})
|
||||
.count()
|
||||
>= MAX_CORRUPT_BACKUPS
|
||||
}
|
||||
|
||||
/// Whether a project has a migration record on disk *at all*, without parsing
|
||||
/// it.
|
||||
///
|
||||
/// The pin reaper needs "is this project's rollback image still somebody's only
|
||||
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
|
||||
/// question on its own — it deliberately reports a corrupt record as absent —
|
||||
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
|
||||
/// what keeps the two answers from disagreeing forever.
|
||||
pub fn has_record(project_id: &str) -> Result<bool, String> {
|
||||
Ok(state_path(project_id)?.exists())
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write a project's migration state.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it, and the missing half is the
|
||||
/// half this record exists for. `fs::write` returns once the bytes are in the
|
||||
/// page cache; a rename over them is atomic *with respect to other readers*,
|
||||
/// not with respect to power loss. Losing power in that window leaves the
|
||||
/// rename applied and the data not yet written — i.e. a 0-byte or truncated
|
||||
/// `{id}.json` — which is precisely the corrupt-record case above, produced by
|
||||
/// the code whose job is to make that case impossible.
|
||||
///
|
||||
/// So: fsync the file before the rename, and fsync the *directory* after it,
|
||||
/// because the rename itself is directory metadata and is not durable until the
|
||||
/// directory is synced. A sync that fails is reported rather than swallowed —
|
||||
/// this is the crash record, and "probably written" is not a state it may be
|
||||
/// in.
|
||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
let data = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush migration state to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||
sync_dir(&path);
|
||||
// A project with a record is not ownerless, whatever a reaper concluded
|
||||
// before this write — so the grace clock is thrown away rather than left to
|
||||
// expire against a pin that now has an owner again.
|
||||
clear_ownerless_for_project(project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power loss.
|
||||
///
|
||||
/// Best effort *only* on the platforms where it is meaningless: Windows has no
|
||||
/// directory handle to sync and returns an error for the attempt, so a failure
|
||||
/// is logged rather than propagated. The file's own `sync_all` above is the
|
||||
/// part that carries the data, and it is not best effort.
|
||||
fn sync_dir(path: &std::path::Path) {
|
||||
let Some(dir) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the migrations directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ownerless-pin tombstones
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker recording **when a rollback pin was first seen with no record behind
|
||||
/// it**.
|
||||
///
|
||||
/// ## Why the grace period cannot be measured from the tag
|
||||
///
|
||||
/// `docker::migration::pin_is_reapable` used to date a pin from the timestamp
|
||||
/// encoded in `pre-migration-<YYYYmmdd-HHMMSS>` — i.e. from when the migration
|
||||
/// *started*. That is the wrong epoch by a whole feature. A migration is
|
||||
/// allowed to sit at `awaiting-confirmation` indefinitely; `keep_rollback`
|
||||
/// exists precisely so a user can run on the new base for a month before
|
||||
/// deciding. If that project's record is then lost — a corrupt file, a deleted
|
||||
/// state file, a half-restored data directory — the pin is fourteen days old on
|
||||
/// the very first check, so it is untagged on the next app start and the
|
||||
/// startup sweep deletes the image two lines later. The fourteen-day grace
|
||||
/// period the constant promises is zero in the only situation it was written
|
||||
/// for.
|
||||
///
|
||||
/// The clock has to start when the *claim* was lost, and nothing on the daemon
|
||||
/// records that moment. So it is written down here, the first time a reaper
|
||||
/// notices, and the age is measured from the marker.
|
||||
///
|
||||
/// One file per `(project_id, tag)` in the migrations directory, holding an
|
||||
/// RFC3339 instant. Tiny, and losing one costs a fresh fourteen days rather
|
||||
/// than a deletion — the failure direction that keeps somebody's only rollback
|
||||
/// copy.
|
||||
fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!(
|
||||
"{}.{}.ownerless",
|
||||
sanitize(project_id),
|
||||
sanitize(tag)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read the first-observed instant for a pin, creating the marker if this is
|
||||
/// the first sighting. Returns `None` when the clock has not started yet.
|
||||
///
|
||||
/// **Clock skew is handled here rather than at the comparison.** A host clock
|
||||
/// that was running fast when the marker was written leaves a timestamp in the
|
||||
/// future; measured naively that is a negative age, which a `num_days() >= 14`
|
||||
/// test reads as "never reapable" — a pin that can never be collected, forever.
|
||||
/// A marker dated after `now` is therefore rewritten to `now`, restarting the
|
||||
/// grace period. The other direction — a clock jumping forward — cannot shorten
|
||||
/// the period below what has actually elapsed on the *marker's* terms, because
|
||||
/// there is nothing to compare against but wall time; what it cannot do any
|
||||
/// more is make every pin instantly reapable, which dating from the tag did.
|
||||
///
|
||||
/// ## Why the write re-checks `has_record`
|
||||
///
|
||||
/// Both reapers ask [`has_record`] and only call this when the answer is no,
|
||||
/// which leaves a window: a [`save`] landing between the two runs its
|
||||
/// `clear_ownerless_for_project` against a marker that does not exist yet, and
|
||||
/// this then plants one — dated *now* — behind a perfectly valid record. The
|
||||
/// marker is invisible while the record stands, so nothing notices. It only
|
||||
/// matters later, if that record is legitimately lost: the pin is then already
|
||||
/// fourteen days ownerless on its very first check and is reaped with **zero**
|
||||
/// grace, which is the exact failure the tombstone exists to prevent.
|
||||
///
|
||||
/// So the write is followed by a second `has_record`, and a marker that turns
|
||||
/// out to sit behind a record is removed again. The two orderings that remain
|
||||
/// are both safe: a `save` completing *after* this re-check clears the marker
|
||||
/// itself, and one completing before it is what the re-check sees.
|
||||
pub fn note_ownerless_since(
|
||||
project_id: &str,
|
||||
tag: &str,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let path = ownerless_marker_path(project_id, tag).ok()?;
|
||||
let existing = fs::read_to_string(&path).ok().and_then(|raw| {
|
||||
chrono::DateTime::parse_from_rfc3339(raw.trim())
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&chrono::Utc))
|
||||
});
|
||||
match existing {
|
||||
Some(seen) if seen <= *now => Some(seen),
|
||||
// Absent, unparseable, or dated in the future: (re)start the clock.
|
||||
_ => {
|
||||
if let Err(e) = fs::write(&path, now.to_rfc3339()) {
|
||||
log::warn!(
|
||||
"Could not record that rollback pin {}:{} is ownerless: {} — its grace \
|
||||
period restarts on the next check",
|
||||
project_id,
|
||||
tag,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A record that appeared while this was being written owns the pin,
|
||||
// and a tombstone behind an owned pin is a fourteen-day head start
|
||||
// on reaping it the moment that record is next lost.
|
||||
if has_record(project_id).unwrap_or(false) {
|
||||
log::debug!(
|
||||
"A migration record for {} appeared while marking {} ownerless; \
|
||||
the marker was dropped again",
|
||||
project_id,
|
||||
tag
|
||||
);
|
||||
clear_ownerless(project_id, tag);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a pin's ownerless marker. Missing is success.
|
||||
///
|
||||
/// Called when the pin is untagged, and when a record reappears for the
|
||||
/// project — a re-migrated project must not inherit the previous run's clock.
|
||||
pub fn clear_ownerless(project_id: &str, tag: &str) {
|
||||
let Ok(path) = ownerless_marker_path(project_id, tag) else {
|
||||
return;
|
||||
};
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("Could not remove {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every ownerless marker belonging to one project.
|
||||
///
|
||||
/// A project that has a record again is by definition not ownerless, whatever
|
||||
/// a reaper concluded before.
|
||||
pub fn clear_ownerless_for_project(project_id: &str) {
|
||||
let Ok(dir) = migrations_dir() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.", sanitize(project_id));
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with(&prefix) && name.ends_with(".ownerless") {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a project's migration state file. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
@@ -104,6 +426,39 @@ pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_of_one_record_are_capped() {
|
||||
// `load` runs on every reconcile, every survey and every reaper pass,
|
||||
// and nothing repairs an unparseable record — so a persistently corrupt
|
||||
// one minted a new timestamped copy every time the clock's second
|
||||
// changed, and nothing ever removed them.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-corrupt-cap-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp dir");
|
||||
let record = dir.join("some-project.json");
|
||||
|
||||
assert!(!corrupt_backups_full(&record), "an empty directory is not full");
|
||||
for n in 0..MAX_CORRUPT_BACKUPS {
|
||||
fs::write(
|
||||
dir.join(format!("some-project.json.corrupt-2026010{}-000000.bak", n)),
|
||||
"x",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&record));
|
||||
|
||||
// Another project's copies, and an unrelated `.bak`, are not this
|
||||
// record's — the prefix is the whole point of the naming.
|
||||
let other = dir.join("other-project.json");
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
fs::write(dir.join("some-project.json.bak"), "x").unwrap();
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
@@ -115,4 +470,43 @@ mod tests {
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
/// The log line must not name a backup that was never written.
|
||||
///
|
||||
/// `load` runs on every reconcile, every survey and every reaper pass, so a
|
||||
/// persistently corrupt record hits the `MAX_CORRUPT_BACKUPS` cap within
|
||||
/// seconds. The previous code folded "already kept enough" into `Ok(())`
|
||||
/// and then reported " (a copy was kept at <path>)" — pointing at a file
|
||||
/// that does not exist. That is the message someone reads immediately
|
||||
/// before going to look for their data.
|
||||
#[test]
|
||||
fn the_corrupt_record_message_only_claims_a_copy_that_exists() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"tc-mstore-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("p.json");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
// Fill the cap with copies that really are on disk.
|
||||
for i in 0..MAX_CORRUPT_BACKUPS {
|
||||
let b = path.with_extension(format!("json.corrupt-2026010{}-000000.bak", i));
|
||||
std::fs::write(&b, b"{ not json").unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&path), "precondition: the cap is reached");
|
||||
|
||||
// With the cap reached, no new copy may be created — and that is the
|
||||
// state in which the old message lied.
|
||||
let before: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
let fresh = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
assert!(
|
||||
!fresh.exists(),
|
||||
"the cap is reached, so this timestamped copy must not be written"
|
||||
);
|
||||
let after: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
assert_eq!(before.len(), after.len(), "nothing new appeared on disk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod migration_store;
|
||||
pub mod notes_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_crypto;
|
||||
pub mod settings_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Host-side persistence for per-project notes.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||
//! free-function shape as `migration_store` — no struct, nothing in
|
||||
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||
//! caches the project list; a store that reads and writes the file per call
|
||||
//! has nothing to cache and nothing to guard.
|
||||
//!
|
||||
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||
//! there would mean the whole project list is rewritten per edit, and a note
|
||||
//! save racing a Config save would silently drop one of them.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Note;
|
||||
|
||||
/// The version stamped into every notes file this build writes.
|
||||
const NOTES_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// What is actually on disk: a version envelope around the notes.
|
||||
///
|
||||
/// The list is wrapped rather than written bare because the wrapper costs
|
||||
/// nothing today and cannot be added cheaply later — once files exist in the
|
||||
/// field, every reader has to sniff two shapes forever. `version` is written
|
||||
/// and read back but nothing branches on it yet: it is the hook a future
|
||||
/// format change hangs off, and its value is only useful if it has been there
|
||||
/// since the first file.
|
||||
///
|
||||
/// Not in `models/` and not exposed over IPC: the frontend receives
|
||||
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
|
||||
/// storage detail rather than part of the IPC contract.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ProjectNotes {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
notes: Vec<Note>,
|
||||
}
|
||||
|
||||
/// Serialises the read-modify-write half of an upsert or delete.
|
||||
///
|
||||
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||
/// those interleaving would lose whichever note was written first. The read
|
||||
/// path does not take it.
|
||||
fn write_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||
pub fn notes_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("notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the notes directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||
|
||||
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||
load_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||
upsert_in(¬es_dir()?, project_id, note)
|
||||
}
|
||||
|
||||
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
delete_in(¬es_dir()?, project_id, note_id)
|
||||
}
|
||||
|
||||
/// Remove a project's notes file entirely. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
// ── Implementation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a project's notes. A missing file is an empty list.
|
||||
///
|
||||
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||
/// that project with no way out through the UI; deleting instead would destroy
|
||||
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||
/// corruption cannot overwrite the first — which is the one taken before
|
||||
/// anything rewrote the file, and therefore the one worth having — and capped,
|
||||
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
|
||||
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||
match parse(&data) {
|
||||
Ok(notes) => Ok(notes),
|
||||
Err(e) => {
|
||||
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||
log::error!(
|
||||
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||
left in place{}",
|
||||
project_id,
|
||||
e,
|
||||
kept.describe()
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a notes file: the versioned envelope, or a bare array.
|
||||
///
|
||||
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
|
||||
/// only ever on a development build, but a developer's own notes are still
|
||||
/// prose nothing else holds a copy of, and the alternative is `load_in`
|
||||
/// declaring a perfectly readable file corrupt. It is read, never written: the
|
||||
/// first save rewrites the file with an envelope.
|
||||
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
|
||||
match serde_json::from_str::<ProjectNotes>(data) {
|
||||
Ok(file) => Ok(file.notes),
|
||||
// Report the envelope's error, not the array's — the envelope is the
|
||||
// shape this store writes, so its message is the one that describes
|
||||
// what is actually wrong with the file.
|
||||
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt notes file are kept.
|
||||
///
|
||||
/// Timestamping fixes "a second corruption overwrote the first" and introduces
|
||||
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
|
||||
/// mount — every project switch, every dock-follows-tab change, every sub-tab
|
||||
/// toggle. A file that is *persistently* unparseable (the normal case, since
|
||||
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
|
||||
/// prose every time the clock's second changed. 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. Same constant, same reasoning as `migration_store`.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
|
||||
/// whether a file exists.
|
||||
///
|
||||
/// Three outcomes, and they must not be conflated. Folding "already kept
|
||||
/// enough" into success and then saying "a copy was kept" names a file that
|
||||
/// was never created — which is what someone reads before going to look for
|
||||
/// their data.
|
||||
enum Kept {
|
||||
Copied(PathBuf),
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere(PathBuf),
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
fn describe(&self) -> String {
|
||||
match self {
|
||||
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.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 file are already saved alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a copy of an unreadable notes file is kept.
|
||||
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file 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 costs at
|
||||
/// most one extra file, and failing closed would drop the very first copy of
|
||||
/// prose nothing else has kept.
|
||||
fn corrupt_backups_full(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
|
||||
}
|
||||
|
||||
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
|
||||
let backup = corrupt_backup_path(path, now);
|
||||
if backup.exists() {
|
||||
return Kept::AlreadyThere(backup);
|
||||
}
|
||||
if corrupt_backups_full(path) {
|
||||
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
|
||||
}
|
||||
match fs::copy(path, &backup) {
|
||||
Ok(_) => Kept::Copied(backup),
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace one note, leaving the rest untouched.
|
||||
///
|
||||
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||
/// `updated_at` is stamped here for the same reason.
|
||||
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||
Some(existing) => {
|
||||
note.created_at = existing.created_at.clone();
|
||||
*existing = note.clone();
|
||||
}
|
||||
None => notes.push(note.clone()),
|
||||
}
|
||||
save_all(dir, project_id, ¬es)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||
/// retry a delete whose result it never saw.
|
||||
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
let before = notes.len();
|
||||
notes.retain(|n| n.id != note_id);
|
||||
if notes.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
save_all(dir, project_id, ¬es)
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = notes_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 notes: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write the whole list.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||
/// applied and the data not written — a truncated file, produced by the code
|
||||
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||
/// the directory after it, since the rename is directory metadata. Notes are
|
||||
/// prose the user typed and nothing else holds a copy.
|
||||
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
let file = ProjectNotes {
|
||||
version: NOTES_FORMAT_VERSION,
|
||||
notes: notes.to_vec(),
|
||||
};
|
||||
let data = serde_json::to_string_pretty(&file)
|
||||
.map_err(|e| format!("Failed to serialize notes: {}", 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 notes: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||
///
|
||||
/// Best effort only 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` carries the data and is not best
|
||||
/// effort.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
log::debug!(
|
||||
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-notes-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.contains(".corrupt-"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_notes_directory() {
|
||||
// The id arrives over IPC. It must not be able to steer the write.
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize("a\\b"), "a_b");
|
||||
// A real UUID must survive untouched, or every note file would move
|
||||
// the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||
let dir = temp_dir("missing");
|
||||
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upserted_note_round_trips() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
assert_eq!(saved.id, note.id);
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||
let dir = temp_dir("replace");
|
||||
let mut note = Note::new("Title".into(), "first".into());
|
||||
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
note.body = "second".into();
|
||||
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||
assert_eq!(loaded[0].body, "second");
|
||||
assert_ne!(
|
||||
saved.created_at, "1999-01-01T00:00:00Z",
|
||||
"created_at is owned by the store, not by whatever the webview sent"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||
let dir = temp_dir("delete");
|
||||
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
|
||||
// Idempotent: removing what is already gone is not an error, because
|
||||
// the UI can retry a delete it never saw the result of.
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||
// Same reasoning as migration_store: a corrupt file must not make the
|
||||
// tab permanently unusable, and the bytes must not be destroyed.
|
||||
let dir = temp_dir("corrupt");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||
assert!(path.exists(), "the unreadable file is left in place");
|
||||
|
||||
assert_eq!(
|
||||
corrupt_copies(&dir).len(),
|
||||
1,
|
||||
"the bytes must be kept exactly once"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
|
||||
// The envelope costs nothing now and cannot be added cheaply once
|
||||
// files exist in the field, so the very first file has to carry it.
|
||||
let dir = temp_dir("envelope");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
|
||||
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["notes"][0]["body"], "b");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
|
||||
// Only a development build ever wrote this shape, but declaring a
|
||||
// perfectly readable file corrupt is the one outcome this store exists
|
||||
// to avoid. It is read, never written back.
|
||||
let dir = temp_dir("legacy");
|
||||
let note = Note::new("Deploy".into(), "one\ntwo".into());
|
||||
std::fs::write(
|
||||
notes_path_in(&dir, "p1"),
|
||||
serde_json::to_string(&vec![note.clone()]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
let copies = corrupt_copies(&dir);
|
||||
assert!(copies.is_empty(), "a readable file must not be copied aside");
|
||||
|
||||
// The next write upgrades it in place.
|
||||
upsert_in(&dir, "p1", note).unwrap();
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
assert!(raw.contains("\"version\""));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_are_capped_rather_than_one_per_second() {
|
||||
// `list_notes` runs on every panel mount, so an unrepaired file would
|
||||
// otherwise mint a full copy of the user's prose every time the
|
||||
// clock's second changed.
|
||||
let dir = temp_dir("cap");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let base = chrono::Utc::now();
|
||||
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
|
||||
let at = base + chrono::Duration::seconds(i);
|
||||
let kept = keep_corrupt_copy(&path, &at);
|
||||
if i < MAX_CORRUPT_BACKUPS as i64 {
|
||||
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
|
||||
"copy {} should be refused by the cap",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_read_in_the_same_second_does_not_re_copy() {
|
||||
let dir = temp_dir("samesecond");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let at = chrono::Utc::now();
|
||||
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
|
||||
assert!(matches!(
|
||||
keep_corrupt_copy(&path, &at),
|
||||
Kept::AlreadyThere(_)
|
||||
));
|
||||
assert_eq!(corrupt_copies(&dir).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_line_never_claims_a_backup_that_was_not_written() {
|
||||
// A message that invents a backup is worse than no message: it is what
|
||||
// someone reads before going to look for their data.
|
||||
let dir = temp_dir("honesty");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
|
||||
assert!(copied.contains("a copy is at"));
|
||||
|
||||
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
|
||||
assert!(refused.contains("no copy kept"));
|
||||
assert!(!refused.contains("a copy is at"));
|
||||
|
||||
let failed = Kept::Failed("permission denied".into()).describe();
|
||||
assert!(failed.contains("could not keep a copy"));
|
||||
assert!(!failed.contains("a copy is at"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_leaves_no_temp_file_behind() {
|
||||
let dir = temp_dir("tmp");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||
let dir = temp_dir("clear");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
assert!(notes_path_in(&dir, "p1").exists());
|
||||
|
||||
clear_in(&dir, "p1").unwrap();
|
||||
assert!(!notes_path_in(&dir, "p1").exists());
|
||||
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||
// `remove_project` must not be able to fail because a project simply
|
||||
// never had any notes — an orphaned notes file is harmless, a project
|
||||
// that cannot be removed is not.
|
||||
let dir = temp_dir("removal");
|
||||
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +241,21 @@ impl ProjectsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Granular setter for the browser view's opt-in, for the same reason
|
||||
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
|
||||
/// the Config tab may be holding an older copy of the whole record.
|
||||
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
p.browser_view_enabled = enabled;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Project {} not found", project_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
@@ -203,3 +268,146 @@ 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();
|
||||
}
|
||||
|
||||
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
|
||||
/// is the real user's; the fields are right here, so the granular setters
|
||||
/// can be exercised against a directory the test owns.
|
||||
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
|
||||
ProjectsStore {
|
||||
projects: Mutex::new(projects),
|
||||
file_path: dir.join("projects.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
|
||||
// The point of the whole exercise: before this the flag lived in a
|
||||
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
|
||||
let dir = temp_dir("browser-view");
|
||||
let project = Project::new("demo".to_string(), Vec::new());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
assert!(store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
// Durable, not merely in memory — this is what a restart reads.
|
||||
let on_disk: Vec<Project> =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
|
||||
assert!(on_disk[0].browser_view_enabled);
|
||||
|
||||
store.set_browser_view_enabled(&id, false).unwrap();
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_granular_toggle_leaves_every_other_field_alone() {
|
||||
// Why these setters exist at all: the Config tab can be holding an
|
||||
// older copy of the whole record while the pane flips one flag.
|
||||
let dir = temp_dir("granular");
|
||||
let mut project = Project::new("demo".to_string(), Vec::new());
|
||||
project.claude_instructions = Some("keep me".to_string());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
store.set_auth_bridge_enabled(&id, false).unwrap();
|
||||
|
||||
let saved = store.get(&id).unwrap();
|
||||
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
|
||||
assert!(saved.browser_view_enabled);
|
||||
assert!(!saved.auth_bridge_enabled);
|
||||
|
||||
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.
|
||||
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);
|
||||
/// 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> {
|
||||
for key_name in PROJECT_SECRET_KEYS {
|
||||
if let Err(e) = delete_project_secret(project_id, key_name) {
|
||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -246,26 +321,125 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
if let Some(existing) = get_gateway_master_key()? {
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Read the gateway master key without minting one if none exists yet.
|
||||
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||
/// effect the read half of that function must not have — settings export
|
||||
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||
/// one exists".
|
||||
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||
.filter(|k| !k.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
store_gateway_master_key(&key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an exact given gateway master key, replacing any previous one.
|
||||
///
|
||||
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||
/// restoring the *same* key an export captured is the point — projects on
|
||||
/// the destination machine may not exist yet, but a project migrated or
|
||||
/// re-added later that still has the old key pasted into its config must
|
||||
/// keep working against it. Blank input is rejected rather than silently
|
||||
/// stored, matching every other `store_*` function in this module.
|
||||
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The regression this list exists for. `openai-compatible-api-key` was
|
||||
/// written by `store_secrets_for_project` and missing from the delete list,
|
||||
/// so it survived project deletion.
|
||||
#[test]
|
||||
fn every_secret_the_app_writes_is_one_it_can_delete() {
|
||||
for key in [
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
"openai-compatible-api-key",
|
||||
] {
|
||||
assert!(
|
||||
PROJECT_SECRET_KEYS.contains(&key),
|
||||
"{} is written by commands/project_commands.rs but would outlive the project",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_list_has_no_duplicates() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for key in PROJECT_SECRET_KEYS {
|
||||
assert!(seen.insert(*key), "duplicate project secret key {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that is not in the list is refused *before* any keychain entry is
|
||||
/// constructed, which is what makes the list authoritative rather than
|
||||
/// advisory. Without this, a new secret can be stored under a name nothing
|
||||
/// ever deletes.
|
||||
#[test]
|
||||
fn an_unlisted_key_cannot_be_stored_at_all() {
|
||||
let err = store_project_secret("some-project", "brand-new-token", "value")
|
||||
.expect_err("an unlisted key must be refused");
|
||||
assert!(
|
||||
err.contains("PROJECT_SECRET_KEYS"),
|
||||
"the refusal should say how to fix it: {}",
|
||||
err
|
||||
);
|
||||
|
||||
let err = get_project_secret("some-project", "brand-new-token")
|
||||
.expect_err("an unlisted key must be refused on read too");
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
|
||||
let err = delete_project_secret("some-project", "brand-new-token")
|
||||
.expect_err("an unlisted key must be refused on delete too");
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
}
|
||||
|
||||
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
|
||||
/// a cleared field arrives as `None` — and before this existed, `None` was
|
||||
/// skipped and the old secret stayed in the keychain forever.
|
||||
#[test]
|
||||
fn a_blanked_field_clears_rather_than_being_skipped() {
|
||||
assert_eq!(secret_to_store(None), None);
|
||||
assert_eq!(secret_to_store(Some("")), None);
|
||||
assert_eq!(secret_to_store(Some(" \t\n")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_value_is_stored_trimmed() {
|
||||
assert_eq!(secret_to_store(Some("ghp_abc123")), Some("ghp_abc123"));
|
||||
// Pasted credentials routinely carry a trailing newline.
|
||||
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Password-based encryption for the settings export/import file — see
|
||||
//! triple-c#35.
|
||||
//!
|
||||
//! The exported payload can carry live credentials (the shared Claude OAuth
|
||||
//! token, the gateway provider/master keys — see
|
||||
//! `commands::settings_export_commands`), so this is not encryption for its
|
||||
//! own sake; a wrong or missing key here is a real credential leak, not a
|
||||
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
|
||||
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
|
||||
//! any reasonable iteration count is not), and AES-256-GCM is what actually
|
||||
//! encrypts — authenticated, so a wrong password is detected by a failed tag
|
||||
//! check rather than producing silent garbage.
|
||||
//!
|
||||
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
|
||||
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
|
||||
//! the clear right here, on purpose. The salt's only job is to make two
|
||||
//! exports with the same password derive different keys (defeats a
|
||||
//! precomputed-table attack against the password alone); the nonce's job is
|
||||
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
|
||||
//! because a fresh random value is drawn for each, on every call to
|
||||
//! [`encrypt`].
|
||||
//!
|
||||
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
|
||||
//! associated data, not just placed alongside the ciphertext — free to do,
|
||||
//! and it makes tampering with any header byte fail the same authentication
|
||||
//! check the ciphertext gets, by construction rather than as a side effect
|
||||
//! of the salt/nonce also feeding key derivation and the cipher.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Identifies the file as a Triple-C settings export and pins the format —
|
||||
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
|
||||
/// new magic value, not a silent reinterpretation of old bytes.
|
||||
const MAGIC: &[u8; 4] = b"TCX1";
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
|
||||
|
||||
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
|
||||
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
|
||||
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
|
||||
/// this runs once per export/import rather than on every request, so trading
|
||||
/// roughly a second of wall time for real brute-force resistance costs
|
||||
/// nothing a user would notice.
|
||||
fn argon2_params() -> Params {
|
||||
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
|
||||
}
|
||||
|
||||
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
|
||||
/// when it drops rather than left in freed memory for whatever reuses that
|
||||
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
|
||||
/// tree via `aes-gcm`) for material that exists only to decrypt live
|
||||
/// credentials.
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
|
||||
let mut key = Zeroizing::new([0u8; KEY_LEN]);
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
|
||||
/// file's bytes (header + ciphertext) — see the module doc for the layout.
|
||||
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
let key = derive_key(password, &salt)?;
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut header = Vec::with_capacity(HEADER_LEN);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.extend_from_slice(&salt);
|
||||
header.extend_from_slice(&nonce_bytes);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
// The header (magic + salt + nonce) is authenticated as associated data
|
||||
// even though none of it is secret: it costs nothing extra here, and it
|
||||
// means tampering with any header byte is caught by the same tag check
|
||||
// that already covers the ciphertext, by construction rather than as a
|
||||
// side effect of the header also feeding key/nonce derivation.
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut out = header;
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
|
||||
/// wrong password is deliberately generic ("wrong password, or the file is
|
||||
/// corrupted") rather than distinguishing the two: GCM's authentication tag
|
||||
/// fails to verify for the wrong key on essentially any ciphertext, so there
|
||||
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
|
||||
/// and guessing would be worse than saying so.
|
||||
///
|
||||
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
|
||||
/// this recovers is the whole settings-plus-secrets payload, so it gets the
|
||||
/// same "wipe it when it drops" treatment as the derived key in
|
||||
/// [`derive_key`].
|
||||
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
|
||||
}
|
||||
if &data[..MAGIC.len()] != MAGIC {
|
||||
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
|
||||
}
|
||||
let header = &data[..HEADER_LEN];
|
||||
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
|
||||
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let key = derive_key(password, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
|
||||
let plaintext = b"{\"settings\": \"whatever\"}";
|
||||
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
|
||||
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(&*decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_fails_rather_than_returning_garbage() {
|
||||
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
|
||||
let result = decrypt(&encrypted, "wrong password");
|
||||
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
|
||||
// If this ever failed it would mean the salt or nonce stopped being
|
||||
// randomized — either one repeating is a real security regression
|
||||
// (a fixed salt lets an attacker precompute against the password
|
||||
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
|
||||
// outright), not just a cosmetic one.
|
||||
let a = encrypt(b"same plaintext", "same password").unwrap();
|
||||
let b = encrypt(b"same plaintext", "same password").unwrap();
|
||||
assert_ne!(a, b, "two independent exports must not be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
|
||||
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
|
||||
let last = encrypted.len() - 1;
|
||||
encrypted[last] ^= 0xFF;
|
||||
assert!(decrypt(&encrypted, "a password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
|
||||
assert!(decrypt(b"short", "any password").is_err());
|
||||
assert!(decrypt(b"", "any password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_the_wrong_magic_is_rejected() {
|
||||
let mut encrypted = encrypt(b"payload", "password").unwrap();
|
||||
encrypted[0] = b'X';
|
||||
assert!(decrypt(&encrypted, "password").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
//! Opening a URL in the *host's* browser — the half of triple-c#34 where
|
||||
//! "Open" appeared to do nothing on Linux.
|
||||
//!
|
||||
//! # Why this module exists rather than `openUrl` from `@tauri-apps/plugin-opener`
|
||||
//!
|
||||
//! The plugin's Linux path shells out to `xdg-open`, and the child inherits
|
||||
//! this process's environment verbatim. Inside an AppImage that environment is
|
||||
//! not the user's — it is the AppImage's, and it is actively hostile to any
|
||||
//! program that is not the one the bundle was built for:
|
||||
//!
|
||||
//! - linuxdeploy's `AppRun`/`AppRun.wrapped` prepends the bundle's own
|
||||
//! directories to `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `PYTHONPATH`,
|
||||
//! `PERLLIB`, `QT_PLUGIN_PATH` and `GSETTINGS_SCHEMA_DIR`.
|
||||
//! - `linuxdeploy-plugin-gtk`'s hook adds `GTK_PATH`, `GTK_EXE_PREFIX`,
|
||||
//! `GTK_DATA_PREFIX`, `GTK_IM_MODULE_FILE`, `GIO_MODULE_DIR` and
|
||||
//! `GDK_PIXBUF_MODULE_FILE`.
|
||||
//! - `scripts/finalize-appimage.sh` installs one more hook of our own
|
||||
//! (`triple-c-wayland-fallback.sh`) that can prepend
|
||||
//! `$APPDIR/usr/lib/wayland-fallback` to `LD_LIBRARY_PATH`.
|
||||
//! - `main.rs` sets `WEBKIT_DISABLE_DMABUF_RENDERER` process-wide, and the
|
||||
//! comment there has flagged this leak for a while: it reaches whatever the
|
||||
//! app spawns afterwards.
|
||||
//!
|
||||
//! A browser that is *already running* is unaffected — `xdg-open` just hands
|
||||
//! the URL to the existing instance over D-Bus/IPC and the new process exits.
|
||||
//! A **cold-launched** browser loads our bundled GTK/glib/pixbuf stack against
|
||||
//! the host's, aborts before it ever paints, and `xdg-open` has already
|
||||
//! returned 0. From the app's point of view the click did nothing. That is the
|
||||
//! reported symptom, and it is why the bug only reproduces for some people.
|
||||
//!
|
||||
//! # What this does instead
|
||||
//!
|
||||
//! `open_url_external` re-validates the URL (see below) and spawns the opener
|
||||
//! with a **sanitized child environment**. Sanitizing is
|
||||
//! [`sanitize_child_env`], a pure function over two maps so it can be tested
|
||||
//! without touching process-wide state:
|
||||
//!
|
||||
//! 1. If the AppImage saved the pre-launch value under a `*_ORIG` /
|
||||
//! `APPIMAGE_ORIGINAL_*` name, restore that. Restoring a saved original is
|
||||
//! strictly better than unsetting, because the user may genuinely have had
|
||||
//! an `LD_LIBRARY_PATH` of their own.
|
||||
//! 2. Otherwise, if the variable differs from the value this process started
|
||||
//! with, restore the start-up value. That is what undoes *our own*
|
||||
//! `std::env::set_var` — `main.rs` snapshots the environment via
|
||||
//! [`capture_pristine_environment`] before any mutation runs.
|
||||
//! 3. Otherwise, drop only the entries that point inside `$APPDIR`, keeping
|
||||
//! the rest of the list intact. Blanket-unsetting would also discard
|
||||
//! whatever the user's session had set; this removes exactly the
|
||||
//! bundle's own contribution.
|
||||
//!
|
||||
//! Nothing is invented: a variable the pristine environment did not have and
|
||||
//! that does not point into `$APPDIR` is left alone, so outside an AppImage
|
||||
//! (`cargo tauri dev`, a distro build) this is very close to a no-op.
|
||||
//!
|
||||
//! # Portal vs. `xdg-open`
|
||||
//!
|
||||
//! `org.freedesktop.portal.OpenURI` would sidestep both the environment leak
|
||||
//! *and* a missing `x-scheme-handler/https` association, but reaching it means
|
||||
//! a D-Bus client — `zbus` and its async stack — as a new dependency for one
|
||||
//! call, on the only platform where we ship a single self-contained binary.
|
||||
//! It also only helps where a portal is running, which is precisely the
|
||||
//! desktop-environment case in which `xdg-open` already works once the
|
||||
//! environment is clean. The environment *is* the bug here, so the cheap fix
|
||||
//! is the complete one. `gio open` is kept as a second candidate because it
|
||||
//! goes through GIO's own handler lookup rather than `xdg-open`'s shell
|
||||
//! heuristics, which covers most of what the portal would have covered.
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! The URL reaching this command originates in an **untrusted container** (see
|
||||
//! `app/src/lib/urlRelay.ts`). The frontend validates with `sanitizeRelayUrl`,
|
||||
//! but a compromised webview can call this command directly, so the rules are
|
||||
//! mirrored here and enforced again: `http`/`https` only, a non-empty host, no
|
||||
//! embedded credentials, no control characters or whitespace, and a length
|
||||
//! cap. The URL is never passed through a shell — `std::process::Command` with
|
||||
//! explicit arguments, so there is no word-splitting, no globbing and no
|
||||
//! metacharacter to escape.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use url::Url;
|
||||
|
||||
/// Hard cap on a URL we will hand to the OS. Mirrors `MAX_RELAY_URL_LENGTH`
|
||||
/// in `app/src/lib/urlRelay.ts`.
|
||||
const MAX_URL_LEN: usize = 8192;
|
||||
|
||||
/// The environment this process was started with, captured before anything
|
||||
/// mutates it. See [`capture_pristine_environment`].
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
static PRISTINE_ENV: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
|
||||
/// Record the environment as it was at process start.
|
||||
///
|
||||
/// Must be called from `main()` **before** any `std::env::set_var` — today
|
||||
/// that means before `apply_webkit_wayland_workaround()`, which is the only
|
||||
/// mutation in the tree. Calling it twice is harmless; the first call wins.
|
||||
///
|
||||
/// This is the only reliable source of truth for "what did the user actually
|
||||
/// have?" for variables *we* set. It cannot recover what `AppRun` overwrote
|
||||
/// before `main()` ran — that is what the `*_ORIG` and `$APPDIR` rules in
|
||||
/// [`sanitize_child_env`] are for.
|
||||
pub fn capture_pristine_environment() {
|
||||
let _ = PRISTINE_ENV.set(std::env::vars().collect());
|
||||
}
|
||||
|
||||
/// Variables an AppImage launcher is known to override, and that break a
|
||||
/// cold-launched child that is not this app.
|
||||
///
|
||||
/// `PATH` is in the list for the same reason as the rest: `AppRun` prepends
|
||||
/// `$APPDIR/usr/bin`, and resolving `xdg-open` (or anything the browser's own
|
||||
/// wrapper script calls) out of the bundle is its own failure mode.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
const SANITIZED_VARS: &[&str] = &[
|
||||
"GDK_PIXBUF_MODULEDIR",
|
||||
"GDK_PIXBUF_MODULE_FILE",
|
||||
"GIO_MODULE_DIR",
|
||||
"GSETTINGS_SCHEMA_DIR",
|
||||
"GTK_DATA_PREFIX",
|
||||
"GTK_EXE_PREFIX",
|
||||
"GTK_IM_MODULE_FILE",
|
||||
"GTK_PATH",
|
||||
"LD_LIBRARY_PATH",
|
||||
"PATH",
|
||||
"PERLLIB",
|
||||
"PYTHONPATH",
|
||||
"QT_PLUGIN_PATH",
|
||||
"XDG_DATA_DIRS",
|
||||
// Set by `main.rs`, not by AppRun — rule 2 (the pristine snapshot) is what
|
||||
// removes it, since the pristine environment almost never has it.
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
];
|
||||
|
||||
/// What to do to one variable in the child: `Some(value)` sets it, `None`
|
||||
/// removes it.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
type EnvChange = (String, Option<String>);
|
||||
|
||||
/// True when `entry` is `appdir` itself or a path inside it.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn is_inside(entry: &str, appdir: &str) -> bool {
|
||||
let appdir = appdir.trim_end_matches('/');
|
||||
if appdir.is_empty() {
|
||||
return false;
|
||||
}
|
||||
entry == appdir || entry.strip_prefix(appdir).is_some_and(|r| r.starts_with('/'))
|
||||
}
|
||||
|
||||
/// Drop the `$APPDIR` entries from a colon-separated list, keeping order and
|
||||
/// keeping everything else.
|
||||
///
|
||||
/// Single-valued variables (`GDK_PIXBUF_MODULE_FILE`, say) are just lists of
|
||||
/// one, so they need no separate case: a value inside `$APPDIR` filters down
|
||||
/// to nothing and the variable is removed.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
|
||||
let kept: Vec<&str> = value
|
||||
.split(':')
|
||||
.filter(|entry| !entry.is_empty() && !is_inside(entry, appdir))
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(kept.join(":"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the changes that turn `current` into an environment safe to hand a
|
||||
/// cold-launched host program.
|
||||
///
|
||||
/// Pure on purpose — `current` and `pristine` are passed in rather than read
|
||||
/// from the process, so the rules can be tested without a global mutex around
|
||||
/// the environment. Returns changes sorted by variable name so assertions are
|
||||
/// deterministic.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn sanitize_child_env(
|
||||
current: &BTreeMap<String, String>,
|
||||
pristine: &BTreeMap<String, String>,
|
||||
appdir: Option<&str>,
|
||||
) -> Vec<EnvChange> {
|
||||
let mut changes: Vec<EnvChange> = Vec::new();
|
||||
|
||||
for var in SANITIZED_VARS {
|
||||
let now = current.get(*var);
|
||||
|
||||
// 1. A saved original always wins. Both spellings are checked because
|
||||
// which one exists depends on the launcher: linuxdeploy's AppRun
|
||||
// and the various `AppRun.wrapped` generations have used each.
|
||||
// An empty saved value means "it was unset", not "set it to empty".
|
||||
let saved = current
|
||||
.get(&format!("{var}_ORIG"))
|
||||
.or_else(|| current.get(&format!("APPIMAGE_ORIGINAL_{var}")));
|
||||
if let Some(saved) = saved {
|
||||
let restored = if saved.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(saved.clone())
|
||||
};
|
||||
if restored.as_ref() != now {
|
||||
changes.push((var.to_string(), restored));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. We changed it ourselves after start-up — put back what was there.
|
||||
let at_start = pristine.get(*var);
|
||||
if at_start != now {
|
||||
changes.push((var.to_string(), at_start.cloned()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Polluted before `main()` ran, with nothing saved. Remove the
|
||||
// bundle's own entries and keep the user's.
|
||||
let (Some(now), Some(appdir)) = (now, appdir) else {
|
||||
continue;
|
||||
};
|
||||
let stripped = strip_appdir_entries(now, appdir);
|
||||
if stripped.as_deref() != Some(now.as_str()) {
|
||||
changes.push((var.to_string(), stripped));
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
changes
|
||||
}
|
||||
|
||||
/// Whether `candidate` holds a character that disqualifies it before parsing.
|
||||
///
|
||||
/// Mirrors `hasForbiddenChar` in `app/src/lib/urlRelay.ts`, and for the same
|
||||
/// reasons: C0/C1 controls and whitespace are invisible in the UI and are
|
||||
/// stripped rather than rejected by some URL parsers, and quote characters are
|
||||
/// illegal in a URL per RFC 3986 while being exactly what an argument-splitting
|
||||
/// opener downstream would act on. Written as a scan over code points rather
|
||||
/// than a regex so the control ranges cannot be mangled by an editing tool.
|
||||
fn has_forbidden_char(candidate: &str) -> bool {
|
||||
candidate.chars().any(|ch| {
|
||||
let code = ch as u32;
|
||||
code <= 0x20
|
||||
|| code == 0x7f
|
||||
|| (0x80..=0x9f).contains(&code)
|
||||
|| ch == '"'
|
||||
|| ch == '\''
|
||||
|| ch == '`'
|
||||
|| ch.is_whitespace()
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a URL an untrusted source asked the host to open.
|
||||
///
|
||||
/// Returns the normalized URL, or a message safe to show the user. The message
|
||||
/// never echoes the input: it is the input that is untrusted, and this error
|
||||
/// is rendered in a toast.
|
||||
fn validate_external_url(raw: &str) -> Result<String, String> {
|
||||
// Rust's `trim` strips slightly more than JavaScript's (NEL, U+0085, for
|
||||
// one), so a string the frontend would have rejected can reach the parser
|
||||
// here with its edges shaved. That only ever removes outer whitespace —
|
||||
// everything that survives still has to pass every check below — so the
|
||||
// divergence cannot widen what gets opened.
|
||||
let candidate = raw.trim();
|
||||
|
||||
if candidate.is_empty() {
|
||||
return Err("Refused to open an empty URL.".to_string());
|
||||
}
|
||||
if candidate.len() > MAX_URL_LEN {
|
||||
return Err(format!(
|
||||
"Refused to open a URL longer than {MAX_URL_LEN} characters."
|
||||
));
|
||||
}
|
||||
if has_forbidden_char(candidate) {
|
||||
return Err(
|
||||
"Refused to open a URL containing whitespace, quotes or control characters."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(candidate).map_err(|_| "Refused to open a malformed URL.".to_string())?;
|
||||
|
||||
// Scheme allowlist. Nothing else, ever — `file:`, `javascript:`, `data:`
|
||||
// and every registered protocol handler stay out of reach of the
|
||||
// container. The scheme is safe to interpolate: the parser restricts it to
|
||||
// ASCII alphanumerics, `+`, `-` and `.`.
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
return Err(format!(
|
||||
"Refused to open a {}: URL — only http and https are allowed.",
|
||||
parsed.scheme()
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_none_or(str::is_empty) {
|
||||
return Err("Refused to open a URL with no host.".to_string());
|
||||
}
|
||||
// `https://claude.ai@evil.tld/x` reads as claude.ai anywhere the string is
|
||||
// truncated, and navigates to evil.tld.
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err("Refused to open a URL containing embedded credentials.".to_string());
|
||||
}
|
||||
|
||||
let normalized = parsed.to_string();
|
||||
if normalized.len() > MAX_URL_LEN {
|
||||
return Err(format!(
|
||||
"Refused to open a URL longer than {MAX_URL_LEN} characters."
|
||||
));
|
||||
}
|
||||
// A normalized http(s) URL is ASCII by construction — the host is
|
||||
// punycoded and everything after it is percent-encoded. Asserting it means
|
||||
// nothing non-ASCII can reach an `execvp` argument, whatever the parser
|
||||
// decides to do in a future version.
|
||||
if !normalized.is_ascii() {
|
||||
return Err("Refused to open a URL with non-ASCII characters.".to_string());
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Openers to try, in order, each as (program, leading arguments).
|
||||
///
|
||||
/// `xdg-open` first because it is what the desktop expects to be asked and
|
||||
/// honours the user's `mimeapps.list`. `gio open` second: it is present
|
||||
/// wherever glib is (which, for a GTK app's host, is everywhere) and resolves
|
||||
/// the handler through GIO rather than `xdg-open`'s shell heuristics, so it
|
||||
/// still works when the `x-scheme-handler/https` association `xdg-open` looks
|
||||
/// for is missing or points at something broken.
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPENERS: &[(&str, &[&str])] = &[("xdg-open", &[]), ("gio", &["open"])];
|
||||
|
||||
/// How long a candidate opener is given to fail before it is assumed to have
|
||||
/// worked.
|
||||
///
|
||||
/// `xdg-open` usually returns immediately (it hands the URL to a running
|
||||
/// browser and exits), but in its generic fallback mode it *is* the browser's
|
||||
/// parent and stays alive for the session. So "still running" cannot be read
|
||||
/// as failure, and "exited non-zero quickly" is the only negative signal there
|
||||
/// is — though not, on its own, a trustworthy one. See
|
||||
/// [`exit_code_means_nothing_was_launched`].
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// Whether a non-zero exit says the opener certainly launched nothing, and so
|
||||
/// that the next candidate can be tried without risking a second tab.
|
||||
///
|
||||
/// The loop used to treat every quick non-zero exit as "it did nothing" and
|
||||
/// fall through. That is safe for most of `xdg-open`'s documented codes — 1
|
||||
/// (syntax), 2 (file not found) and 3 (a required tool could not be found) are
|
||||
/// all statements that it never got as far as launching a handler, and 3 is the
|
||||
/// missing-association case `gio open` is in [`OPENERS`] for. 127 is the same
|
||||
/// statement made by a shell, which is how a `$BROWSER` or `x-www-browser`
|
||||
/// wrapper naming a program that does not exist comes back.
|
||||
///
|
||||
/// Code 4 is the one that cannot be read that way, and it is the catch-all:
|
||||
/// "the action failed" also covers a handler that *was* launched and then
|
||||
/// returned non-zero. A browser that takes the URL, opens the tab in an already
|
||||
/// running instance and exits non-zero for its own reasons ends up here, as
|
||||
/// does a wrapper script that does its job and then returns the exit status of
|
||||
/// something else. Falling through on that hands the same URL to a second
|
||||
/// opener: two tabs for one click, and for an OAuth link two authorize
|
||||
/// requests.
|
||||
///
|
||||
/// So anything not recognised below — 4, an unfamiliar code, or a death by
|
||||
/// signal (`code()` is `None`) — ends the loop rather than continuing it. The
|
||||
/// caller is told the opener failed, which is the honest report of an
|
||||
/// ambiguous outcome, and no second request is made on the user's behalf. Note
|
||||
/// what this costs: an opener that genuinely failed with code 4 no longer falls
|
||||
/// through to `gio`, so a user whose `xdg-open` fails that way sees an error
|
||||
/// where they previously might have got a tab.
|
||||
///
|
||||
/// This is reasoning from `xdg-open`'s documented exit codes, not from an
|
||||
/// observed double-open in this app.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn exit_code_means_nothing_was_launched(code: Option<i32>) -> bool {
|
||||
matches!(code, Some(1 | 2 | 3 | 127))
|
||||
}
|
||||
|
||||
/// Spawn `url` with an opener, under a sanitized environment.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_with_clean_env(url: &str) -> Result<(), String> {
|
||||
let current: BTreeMap<String, String> = std::env::vars().collect();
|
||||
let pristine = PRISTINE_ENV.get().cloned().unwrap_or_else(|| current.clone());
|
||||
let appdir = current.get("APPDIR").cloned();
|
||||
let changes = sanitize_child_env(¤t, &pristine, appdir.as_deref());
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for (program, leading) in OPENERS {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(*leading).arg(url);
|
||||
// The bundle's own identity is not the child's business either, and a
|
||||
// browser that re-execs itself through a wrapper script can pick these
|
||||
// up.
|
||||
for var in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] {
|
||||
command.env_remove(var);
|
||||
}
|
||||
for (key, value) in &changes {
|
||||
match value {
|
||||
Some(value) => command.env(key, value),
|
||||
None => command.env_remove(key),
|
||||
};
|
||||
}
|
||||
// Detached: the opener must not inherit our stdio, or a browser
|
||||
// writing to stderr keeps a pipe to us open for the session.
|
||||
command
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
|
||||
// A spawn failure — `ErrorKind::NotFound` for an opener that is not
|
||||
// installed, `PermissionDenied` for one that cannot be executed — is
|
||||
// the unambiguous case: nothing ran, so nothing was opened, and the
|
||||
// next candidate is free to try.
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
failures.push(format!("{program}: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
std::thread::sleep(OPENER_GRACE);
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if !status.success() => {
|
||||
failures.push(format!("{program} exited with {status}"));
|
||||
// A program that *ran* is not a program that did nothing.
|
||||
if !exit_code_means_nothing_was_launched(status.code()) {
|
||||
return Err(format!(
|
||||
"Could not confirm the link opened. Tried: {}. It may have opened anyway \
|
||||
— check your browser before trying again.",
|
||||
failures.join("; ")
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
failures.push(format!("{program}: could not be waited on: {err}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Still running (it is the browser's parent) — reap it off-thread so it
|
||||
// does not become a zombie for the life of the app.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not open the link. Tried: {}. Check that xdg-utils is installed and that a default browser is set.",
|
||||
failures.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Open `url` in the user's browser.
|
||||
///
|
||||
/// On Linux this goes through [`spawn_with_clean_env`] rather than
|
||||
/// `@tauri-apps/plugin-opener`, for the AppImage reasons in this module's
|
||||
/// documentation (triple-c#34). macOS and Windows keep the plugin's path —
|
||||
/// neither has the environment problem, and `open`/`ShellExecute` are the
|
||||
/// right calls there — but they are reached through this same command so the
|
||||
/// frontend has one call site with one set of validation rules.
|
||||
///
|
||||
/// Errors are returned rather than logged-and-swallowed: "Open" silently doing
|
||||
/// nothing is the bug being fixed, so the failure has to be something the UI
|
||||
/// can show.
|
||||
#[tauri::command]
|
||||
pub async fn open_url_external(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
let validated = validate_external_url(&url)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = &app;
|
||||
tauri::async_runtime::spawn_blocking(move || spawn_with_clean_env(&validated))
|
||||
.await
|
||||
.map_err(|err| format!("Could not open the link: {err}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_url(validated, None::<&str>)
|
||||
.map_err(|err| format!("Could not open the link: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── URL re-validation ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn plain_http_and_https_urls_are_accepted() {
|
||||
for url in [
|
||||
"https://claude.ai/",
|
||||
"http://localhost:1420/callback?code=abc",
|
||||
"https://example.com/path#frag",
|
||||
] {
|
||||
assert!(validate_external_url(url).is_ok(), "{url} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_returned_normalized() {
|
||||
assert_eq!(
|
||||
validate_external_url("https://Example.COM").unwrap(),
|
||||
"https://example.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_http_and_https_survive() {
|
||||
for url in [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,<script>",
|
||||
"ftp://example.com/x",
|
||||
"vscode://foo/bar",
|
||||
"mailto:someone@example.com",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_credentials_are_refused() {
|
||||
for url in [
|
||||
"https://claude.ai@evil.tld/x",
|
||||
"https://user:pass@example.com/",
|
||||
"https://:pass@example.com/",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_whitespace_are_refused() {
|
||||
// `\n` in particular: parsers that strip it would turn the first of
|
||||
// these into a `javascript:` URL.
|
||||
for url in [
|
||||
"java\nscript:alert(1)",
|
||||
"https://example.com/\u{7f}",
|
||||
"https://example.com/\u{85}x",
|
||||
"https://example.com/a b",
|
||||
"https://example.com/\u{00a0}x",
|
||||
"https://example.com/\"",
|
||||
"https://example.com/'",
|
||||
"https://example.com/`",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url:?} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_oversized_are_refused() {
|
||||
assert!(validate_external_url("").is_err());
|
||||
assert!(validate_external_url(" ").is_err());
|
||||
let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
|
||||
assert!(validate_external_url(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_is_required() {
|
||||
assert!(validate_external_url("https://").is_err());
|
||||
assert!(validate_external_url("http://:8080/").is_err());
|
||||
// Not a missing host: WHATWG's "special authority ignore slashes"
|
||||
// state eats the third slash, so this is the host `path` in both
|
||||
// `new URL()` and here. Asserted so the parity is on the record.
|
||||
assert_eq!(
|
||||
validate_external_url("http:///path").unwrap(),
|
||||
"http://path/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_never_echo_the_input() {
|
||||
// The input is attacker-controlled and the message goes into a toast.
|
||||
let err = validate_external_url("file:///home/someone/.ssh/id_rsa").unwrap_err();
|
||||
assert!(!err.contains("id_rsa"), "message leaked the input: {err}");
|
||||
}
|
||||
|
||||
// ── Environment sanitization ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn appdir_entries_are_stripped_and_the_users_own_are_kept() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib:/opt/mine/lib"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_abc/usr/share:/usr/share"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![
|
||||
(
|
||||
"LD_LIBRARY_PATH".to_string(),
|
||||
Some("/opt/mine/lib".to_string())
|
||||
),
|
||||
("XDG_DATA_DIRS".to_string(), Some("/usr/share".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_that_is_entirely_appdir_is_removed() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
|
||||
(
|
||||
"GDK_PIXBUF_MODULE_FILE",
|
||||
"/tmp/.mount_abc/usr/lib/gdk-pixbuf/loaders.cache",
|
||||
),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![
|
||||
("GDK_PIXBUF_MODULE_FILE".to_string(), None),
|
||||
("GTK_PATH".to_string(), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_original_is_restored_rather_than_unset() {
|
||||
// Restoring beats unsetting: the user may have had one of their own.
|
||||
for saved_as in ["LD_LIBRARY_PATH_ORIG", "APPIMAGE_ORIGINAL_LD_LIBRARY_PATH"] {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
|
||||
(saved_as, "/home/someone/lib"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![(
|
||||
"LD_LIBRARY_PATH".to_string(),
|
||||
Some("/home/someone/lib".to_string())
|
||||
)],
|
||||
"{saved_as} should be restored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_saved_original_means_it_was_unset() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
|
||||
("LD_LIBRARY_PATH_ORIG", ""),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(changes, vec![("LD_LIBRARY_PATH".to_string(), None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_set_var_is_undone_from_the_pristine_snapshot() {
|
||||
// The leak `main.rs` documents: we set this after start-up, so the
|
||||
// start-up snapshot is what says it should not exist at all.
|
||||
let pristine = map(&[("HOME", "/home/someone")]);
|
||||
let current = map(&[
|
||||
("HOME", "/home/someone"),
|
||||
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, &pristine, None);
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_the_user_set_themselves_is_left_alone() {
|
||||
let pristine = map(&[("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]);
|
||||
let current = pristine.clone();
|
||||
assert!(sanitize_child_env(¤t, &pristine, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_an_appimage_nothing_is_touched() {
|
||||
let env = map(&[
|
||||
("PATH", "/usr/bin:/bin"),
|
||||
("LD_LIBRARY_PATH", "/opt/mine/lib"),
|
||||
("XDG_DATA_DIRS", "/usr/share"),
|
||||
]);
|
||||
assert!(
|
||||
sanitize_child_env(&env, &env, None).is_empty(),
|
||||
"a dev build or distro build must not have its environment rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_invented_for_variables_that_were_never_set() {
|
||||
let env = map(&[("APPDIR", "/tmp/.mount_abc")]);
|
||||
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_that_merely_looks_like_appdir_is_not_stripped() {
|
||||
// `/tmp/.mount_abc-other` is not inside `/tmp/.mount_abc`.
|
||||
let env = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc-other/lib"),
|
||||
]);
|
||||
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_slash_on_appdir_still_matches() {
|
||||
let env = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc/"),
|
||||
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
|
||||
]);
|
||||
let changes = sanitize_child_env(&env, &env, Some("/tmp/.mount_abc/"));
|
||||
assert_eq!(changes, vec![("GTK_PATH".to_string(), None)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod opener_fallback_tests {
|
||||
use super::*;
|
||||
|
||||
/// The codes `xdg-open` documents as "nothing was launched". Falling
|
||||
/// through to the next opener on these is what keeps `gio open` reachable
|
||||
/// for the case it was added for: no usable `x-scheme-handler/https`
|
||||
/// association.
|
||||
#[test]
|
||||
fn the_codes_that_mean_no_handler_ran_fall_through() {
|
||||
for code in [1, 2, 3, 127] {
|
||||
assert!(
|
||||
exit_code_means_nothing_was_launched(Some(code)),
|
||||
"exit {code} means the opener never launched anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression this guards: `xdg-open` returns 4 both when it could not
|
||||
/// act and when the handler it launched returned non-zero — including a
|
||||
/// browser that had already opened the tab. Trying `gio open` next would
|
||||
/// open it a second time, which for an OAuth URL is a second authorize
|
||||
/// request.
|
||||
#[test]
|
||||
fn an_exit_that_may_follow_a_successful_open_does_not_fall_through() {
|
||||
assert!(!exit_code_means_nothing_was_launched(Some(4)));
|
||||
for code in [5, 7, 126, 255] {
|
||||
assert!(
|
||||
!exit_code_means_nothing_was_launched(Some(code)),
|
||||
"exit {code} is not a documented 'did nothing', so it must not be assumed to be one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Killed by a signal: `code()` is `None` and the outcome is unknowable,
|
||||
/// so it is treated like any other unrecognised exit.
|
||||
#[test]
|
||||
fn a_death_by_signal_does_not_fall_through() {
|
||||
assert!(!exit_code_means_nothing_was_launched(None));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -196,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
|
||||
writer_handle.abort();
|
||||
}
|
||||
|
||||
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
|
||||
/// copied so the web terminal cannot drift from it — a duplicated `const` with
|
||||
/// a "keep these identical" comment is only as good as the next reader.
|
||||
use crate::commands::terminal_commands::UPDATE_PRELUDE;
|
||||
|
||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
@@ -207,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
// The args are interpolated into a shell script string below, so
|
||||
// single-quote each one.
|
||||
let permission_flags: String = permission_args
|
||||
@@ -226,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
@@ -250,9 +267,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
@@ -319,6 +338,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": {
|
||||
@@ -33,6 +33,7 @@
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,11 +4,13 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
import NotesDock from "./components/layout/NotesDock";
|
||||
import TerminalView from "./components/terminal/TerminalView";
|
||||
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,23 +130,39 @@ 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}
|
||||
visible={activeTabKey === homeTabKey(projectId)}
|
||||
>
|
||||
<ProjectHome
|
||||
projectId={projectId}
|
||||
active={activeTabKey === homeTabKey(projectId)}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<TerminalView
|
||||
<PaneVisibilityProvider
|
||||
key={session.id}
|
||||
visible={session.id === activeSessionId}
|
||||
>
|
||||
<TerminalView
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<NotesDock />
|
||||
</div>
|
||||
<StatusBar stt={stt} />
|
||||
<ToastHost />
|
||||
@@ -156,6 +174,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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||
import { openUrlExternal } from "../lib/tauri-commands";
|
||||
import { useDocker } from "../hooks/useDocker";
|
||||
import Modal from "./ui/Modal";
|
||||
import Button from "./ui/Button";
|
||||
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
||||
const handleOpenDocs = async () => {
|
||||
if (!options) return;
|
||||
try {
|
||||
await openUrl(options.docs_url);
|
||||
await openUrlExternal(options.docs_url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open docs URL:", e);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../store/appState";
|
||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import type { PermissionMode } from "../../lib/types";
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -195,11 +196,10 @@ export default function MainTabs() {
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
return sessionDisplayName(
|
||||
session,
|
||||
projects.find((p) => p.id === session.projectId),
|
||||
);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
@@ -358,13 +358,7 @@ export default function MainTabs() {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const displayLabel = sessionDisplayName(session, project);
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NotesDock from "./NotesDock";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
vi.mock("../notes/NotesDockPanel", () => ({
|
||||
default: ({ projectId }: { projectId: string }) => (
|
||||
<div data-testid="panel">{`panel:${projectId}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let state: Record<string, unknown> = {};
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
isHomeTab: (k: string) => k.startsWith("home:"),
|
||||
isTerminalTab: (k: string) => k.startsWith("term:"),
|
||||
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
|
||||
// The mocked store module still needs to supply the width constants the
|
||||
// dock imports from it for the separator's aria-value attributes.
|
||||
NOTES_DOCK_MIN_WIDTH: 260,
|
||||
NOTES_DOCK_MAX_WIDTH: 720,
|
||||
}));
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: "s1",
|
||||
projectId: "p9",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
notesDockOpen: true,
|
||||
setNotesDockOpen: vi.fn(),
|
||||
toggleNotesDock: vi.fn(),
|
||||
notesDockWidth: 352,
|
||||
setNotesDockWidth: vi.fn(),
|
||||
activeTabKey: null,
|
||||
sessions: [session],
|
||||
projects: [{ id: "p9", name: "api" } as unknown as Project],
|
||||
};
|
||||
});
|
||||
|
||||
describe("NotesDock", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
state.notesDockOpen = false;
|
||||
const { container } = render(<NotesDock />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("follows a project home tab", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
|
||||
});
|
||||
|
||||
it("follows the project of the active terminal tab", () => {
|
||||
// The dock exists to be visible while the agent runs, so a terminal tab
|
||||
// must resolve to its project, not to nothing.
|
||||
state.activeTabKey = "term:s1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
|
||||
});
|
||||
|
||||
it("explains itself when no project is active", () => {
|
||||
state.activeTabKey = null;
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing for a terminal whose session has gone", () => {
|
||||
state.activeTabKey = "term:vanished";
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at the stored width", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
state.notesDockWidth = 420;
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
|
||||
});
|
||||
|
||||
it("has a keyboard-reachable resize handle", () => {
|
||||
// Drag is a mouse gesture; a separator that only responds to pointer
|
||||
// events is unusable without one.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
|
||||
// The dock sits on the right edge, so dragging or pressing left grows it
|
||||
// and right shrinks it. Asserting only "was called" would pass even if
|
||||
// the branches were swapped or the sign inverted.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
isTerminalTab,
|
||||
tabKeyId,
|
||||
NOTES_DOCK_MIN_WIDTH,
|
||||
NOTES_DOCK_MAX_WIDTH,
|
||||
} from "../../store/appState";
|
||||
import NotesDockPanel from "../notes/NotesDockPanel";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
* Notes beside whatever is on screen.
|
||||
*
|
||||
* Project Home and Terminal are sibling top-level tabs, so notes living only
|
||||
* in a sub-tab would be hidden exactly when the agent is running — which is
|
||||
* when a note is worth sending. The dock is the answer to that.
|
||||
*
|
||||
* **It takes space from inside the window and never resizes it.** Growing the
|
||||
* OS window was tried and rejected on evidence: honoured under XWayland,
|
||||
* silently corrupting under native Wayland, where `outer_position()` returns a
|
||||
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
|
||||
* §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s
|
||||
* ResizeObserver already reflows xterm and resizes the container PTY.
|
||||
*/
|
||||
export default function NotesDock() {
|
||||
const {
|
||||
notesDockOpen,
|
||||
setNotesDockOpen,
|
||||
notesDockWidth,
|
||||
setNotesDockWidth,
|
||||
activeTabKey,
|
||||
sessions,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
setNotesDockOpen: s.setNotesDockOpen,
|
||||
notesDockWidth: s.notesDockWidth,
|
||||
setNotesDockWidth: s.setNotesDockWidth,
|
||||
activeTabKey: s.activeTabKey,
|
||||
sessions: s.sessions,
|
||||
})),
|
||||
);
|
||||
|
||||
// Dragging the separator. Pointer capture rather than window listeners, so
|
||||
// the drag survives the pointer crossing the terminal — which swallows
|
||||
// events — and ends correctly if the button is released outside the window.
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const handle = e.currentTarget;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX;
|
||||
const startWidth = notesDockWidth;
|
||||
// The dock is on the right, so dragging left widens it.
|
||||
const onMove = (move: PointerEvent) =>
|
||||
setNotesDockWidth(startWidth + (startX - move.clientX));
|
||||
const onUp = () => {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
handle.removeEventListener("pointermove", onMove);
|
||||
handle.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
handle.addEventListener("pointermove", onMove);
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 64 : 16;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth + step);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth - step);
|
||||
}
|
||||
};
|
||||
|
||||
if (!notesDockOpen) return null;
|
||||
|
||||
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||
// is the project it belongs to.
|
||||
let projectId: string | null = null;
|
||||
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||
projectId = tabKeyId(activeTabKey);
|
||||
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
|
||||
projectId =
|
||||
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Notes"
|
||||
style={{ width: `${notesDockWidth}px` }}
|
||||
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
|
||||
>
|
||||
{/* Separator, not decoration: it carries a role and arrow keys, because
|
||||
a resize that only answers to a drag is unavailable to anyone not
|
||||
using a mouse. */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize notes panel"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={notesDockWidth}
|
||||
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
|
||||
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
|
||||
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{projectId ? (
|
||||
<NotesDockPanel projectId={projectId} />
|
||||
) : (
|
||||
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
|
||||
Open a project or a terminal to see its notes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function StatusBar({ stt }: Props) {
|
||||
const {
|
||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||
terminalAtBottom, scrollActiveToBottom,
|
||||
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
projects: s.projects,
|
||||
@@ -18,11 +18,18 @@ export default function StatusBar({ stt }: Props) {
|
||||
terminalHasSelection: s.terminalHasSelection,
|
||||
activeSessionId: s.activeSessionId,
|
||||
sttEnabled: s.appSettings?.stt?.enabled,
|
||||
terminalAtBottom: s.terminalAtBottom,
|
||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
toggleNotesDock: s.toggleNotesDock,
|
||||
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||
releaseActiveMouse: s.releaseActiveMouse,
|
||||
}))
|
||||
);
|
||||
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,17 +52,34 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
{!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: mouse release + Notes + STT mic */}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{activeSessionId && !terminalAtBottom && (
|
||||
{activeSessionId && terminalMouseCaptured && (
|
||||
<button
|
||||
onClick={() => scrollActiveToBottom()}
|
||||
data-mouse-release="true"
|
||||
onClick={() => releaseActiveMouse()}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Scroll the terminal to the latest output"
|
||||
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
|
||||
>
|
||||
Jump to Current ↓
|
||||
🖱 Mouse captured — release
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleNotesDock}
|
||||
aria-pressed={notesDockOpen}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Show or hide the notes panel beside the current tab"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
{sttEnabled && activeSessionId && (
|
||||
<SttButton
|
||||
state={stt.state}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onBodyChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and body, saved when a field loses focus.
|
||||
*
|
||||
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||
* so there is no moment where the text on screen is not the text that would be
|
||||
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||
* than nearly true.
|
||||
*/
|
||||
export default function NoteEditor({
|
||||
projectId,
|
||||
title,
|
||||
body,
|
||||
onTitleChange,
|
||||
onBodyChange,
|
||||
onCommit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||
{/* Wraps rather than overflows. The two buttons are a group with a fixed
|
||||
appetite (~190px) and the title field can shrink only so far, so in a
|
||||
narrow dock the title takes the first row and the buttons the second.
|
||||
Without the wrap the group is simply clipped by the dock's
|
||||
`overflow-hidden`, which puts Delete off-window with no scrollbar to
|
||||
reach it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-24 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* The live editor text, not `note.body` — what is on screen is what
|
||||
gets sent. */}
|
||||
<SendToAgentButton projectId={projectId} body={body} />
|
||||
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => onBodyChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||
prompt — you press Enter.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const onTitleChange = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") =>
|
||||
render(
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selectedId}
|
||||
title={title}
|
||||
onTitleChange={onTitleChange}
|
||||
onCommit={onCommit}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("NoteSwitcher", () => {
|
||||
it("edits the title in place, committing on blur", () => {
|
||||
setup([note()]);
|
||||
const field = screen.getByLabelText("Note title");
|
||||
expect(field).toHaveValue("Deploy steps");
|
||||
|
||||
fireEvent.change(field, { target: { value: "Deploy steps v2" } });
|
||||
expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2");
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(field);
|
||||
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the other notes out of the way until asked for", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports whether the list is open", () => {
|
||||
setup([note()]);
|
||||
const trigger = screen.getByRole("button", { name: /switch note/i });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("marks the current note as the selected option", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas");
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a note and closes", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names an untitled note rather than showing an empty row", () => {
|
||||
setup([note({ title: " " })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Notes are addressed by id, never by title. Two untitled notes are the
|
||||
// ordinary case, and a title-keyed list would collapse them into one row.
|
||||
it("lists two notes that share a title as two options", () => {
|
||||
setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
const options = screen.getAllByRole("option", { name: "Untitled note" });
|
||||
expect(options).toHaveLength(2);
|
||||
|
||||
fireEvent.click(options[1]);
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
});
|
||||
|
||||
it("closes on Escape without selecting anything", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
export const UNTITLED = "Untitled note";
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
selectedId: string;
|
||||
title: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row that both names the current note and switches to another.
|
||||
*
|
||||
* The dock has no room for a permanent list of titles, so the title field
|
||||
* doubles as the label of what is open and the chevron beside it holds the
|
||||
* rest. Renaming therefore needs no separate affordance.
|
||||
*
|
||||
* Two honest controls rather than one `role="combobox"`: a text field and a
|
||||
* button that opens a listbox. A real combobox owes its listbox keyboard
|
||||
* navigation, active-descendant tracking and an input that filters — none of
|
||||
* which this needs, and half of which is worse than not claiming the role.
|
||||
*
|
||||
* `OverflowMenu` is deliberately not reused here despite the shape being
|
||||
* close. It keys its items by label, and notes are addressed by id: two
|
||||
* untitled notes are the ordinary case and would collapse into one row.
|
||||
*/
|
||||
export default function NoteSwitcher({
|
||||
notes,
|
||||
selectedId,
|
||||
title,
|
||||
onTitleChange,
|
||||
onCommit,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true" className="leading-none text-[10px]">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Notes"
|
||||
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
|
||||
would put an implicit `listitem` between the listbox and its
|
||||
options, which is not a child role a listbox owns. */}
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={n.id === selectedId}
|
||||
onClick={() => {
|
||||
onSelect(n.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
|
||||
n.id === selectedId
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
|
||||
: "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendProps: Record<string, unknown>[] = [];
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
sendProps.push(props);
|
||||
return <button type="button">Send to agent</button>;
|
||||
},
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendProps.length = 0;
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesDockPanel", () => {
|
||||
it("says it is loading rather than flashing an empty state", () => {
|
||||
loading = true;
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a first note when the project has none", async () => {
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// The point of the redesign: the dock spends its height on the note being
|
||||
// written, not on a permanent list of the ones that are not.
|
||||
it("shows one note at a time, the rest behind the switcher", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the note picked from the list", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("careful");
|
||||
});
|
||||
|
||||
it("saves the body when it loses focus, and not before", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
|
||||
it("opens the note it just created", async () => {
|
||||
notes = [note()];
|
||||
createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
|
||||
const view = render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
|
||||
notes = [note(), note({ id: "n9", title: "" })];
|
||||
view.rerender(<NotesDockPanel projectId="p1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue(""),
|
||||
);
|
||||
});
|
||||
|
||||
// The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
|
||||
// panel, so both of these are load-bearing rather than cosmetic.
|
||||
it("sends from a full-width bar whose menu opens upward", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
|
||||
expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
|
||||
});
|
||||
|
||||
it("sends what is on screen, not what was last saved", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), {
|
||||
target: { value: "edited but not blurred" },
|
||||
});
|
||||
|
||||
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
import OverflowMenu from "../ui/OverflowMenu";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes at dock width.
|
||||
*
|
||||
* Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
|
||||
* of titles beside the editor; the dock cannot, and shrinking that layout
|
||||
* spends its height on chrome — a title strip, a wrapped button row and a
|
||||
* paragraph of help — for a body that ends up a few words wide.
|
||||
*
|
||||
* So the dock shows exactly one note. The title row names it and switches to
|
||||
* another, the actions that are not writing live in the overflow menu, and
|
||||
* everything left over is the body. Roughly 240px of height comes back.
|
||||
*
|
||||
* What the two surfaces share is the part that must not drift: `useNotes` for
|
||||
* the cache and its write ordering, and `useNoteDraft` for when a keystroke
|
||||
* becomes a save. Only the layout is different.
|
||||
*/
|
||||
export default function NotesDockPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
Keep reminders here, and send any of them straight to a running Claude
|
||||
session.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selected.id}
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
onCommit={commit}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
{/* Renders nothing while idle, so it costs no width until it matters. */}
|
||||
<SaveIndicator state={saveState} />
|
||||
<OverflowMenu
|
||||
label="Note actions"
|
||||
items={[
|
||||
{ label: "New note", onSelect: () => void onCreate() },
|
||||
{
|
||||
label: "Delete note",
|
||||
danger: true,
|
||||
onSelect: () => void deleteNote(selected.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={commit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
|
||||
/>
|
||||
|
||||
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
|
||||
{/* The live draft, not `selected.body` — what is on screen is what gets
|
||||
sent. `dropUp` because the dock clips its own overflow. */}
|
||||
<SendToAgentButton
|
||||
projectId={projectId}
|
||||
body={body}
|
||||
fullWidth
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Two panels, one project — the configuration the app actually runs in.
|
||||
*
|
||||
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
|
||||
* and the dock follows the active tab's project, so opening the dock over a
|
||||
* Project Home tab mounts both for the *same* project. Every other notes test
|
||||
* mounts exactly one, which is precisely the configuration in which a
|
||||
* per-panel cache looks correct: it is only with two that an edit made in one
|
||||
* is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
|
||||
* here; the cache is what is under test.
|
||||
*
|
||||
* The two are different components on purpose, which is exactly why this test
|
||||
* pairs them rather than mounting the same one twice: the layouts diverged,
|
||||
* and the cache and draft rules they share are what must not.
|
||||
*/
|
||||
|
||||
const files: Record<string, Note[]> = {};
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
listNotes: async (p: string) => [...(files[p] ?? [])],
|
||||
saveNote: async (p: string, n: Note) => {
|
||||
const list = files[p] ?? (files[p] = []);
|
||||
const at = list.findIndex((x) => x.id === n.id);
|
||||
if (at === -1) list.unshift(n);
|
||||
else list[at] = n;
|
||||
return n;
|
||||
},
|
||||
deleteNote: async (p: string, id: string) => {
|
||||
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: () => <button type="button">Send to agent</button>,
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The tab and the dock, mounted together the way `App` mounts them. */
|
||||
function renderBothSurfaces() {
|
||||
render(
|
||||
<>
|
||||
<div data-testid="tab">
|
||||
<NotesPanel projectId="p1" />
|
||||
</div>
|
||||
<div data-testid="dock">
|
||||
<NotesDockPanel projectId="p1" />
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
return {
|
||||
tab: () => within(screen.getByTestId("tab")),
|
||||
dock: () => within(screen.getByTestId("dock")),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(files)) delete files[key];
|
||||
files.p1 = [note()];
|
||||
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||
});
|
||||
|
||||
describe("the tab and the dock both open on one project", () => {
|
||||
it("shows an edit made in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
|
||||
// The other surface's list *and* its editor, not just one of them.
|
||||
await waitFor(() =>
|
||||
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
|
||||
);
|
||||
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("does not write one surface's stale copy over the other's edit", async () => {
|
||||
// The reported repro: edit in the dock, then go back to the tab and edit
|
||||
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
|
||||
// and the dock's edit was gone from disk with no error and no indicator.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "two" } });
|
||||
fireEvent.blur(tabBody);
|
||||
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("two"));
|
||||
expect(files.p1).toHaveLength(1);
|
||||
expect(files.p1[0].title).toBe("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("reads the project once for both surfaces", async () => {
|
||||
// Two panels are two `useNotes`, but the in-flight flag is per project, so
|
||||
// mounting the dock over an open Notes tab does not re-read the file.
|
||||
const listNotes = vi.spyOn(
|
||||
await import("../../lib/tauri-commands"),
|
||||
"listNotes",
|
||||
);
|
||||
renderBothSurfaces();
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
|
||||
);
|
||||
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||
listNotes.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps text the user is part-way through typing when the other surface saves", async () => {
|
||||
// Showing a remote edit must never mean discarding an unsaved local one.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "half-typed" } });
|
||||
|
||||
const dockBody = dock().getByLabelText("Note body");
|
||||
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
|
||||
fireEvent.blur(dockBody);
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
|
||||
|
||||
expect(tabBody).toHaveValue("half-typed");
|
||||
});
|
||||
|
||||
it("falls back to another note when the selected one is deleted", async () => {
|
||||
// The claim a differently-named test in NotesPanel.test.tsx used to make
|
||||
// and could not keep: `useNotes` is mocked there and its list never
|
||||
// changes, so the fallback was invisible. Here the list is real.
|
||||
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
const { tab } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
|
||||
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
|
||||
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
|
||||
expect(files.p1).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a note created in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
// The dock keeps New behind its overflow menu — its height belongs to the
|
||||
// note being written, not to a button row.
|
||||
fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
|
||||
);
|
||||
expect(files.p1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: ({ body }: { body: string }) => (
|
||||
<button type="button" data-testid="send">{`send:${body}`}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesPanel", () => {
|
||||
it("invites the user to start when there are no notes", () => {
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists notes by title and selects the first", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
||||
});
|
||||
|
||||
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
||||
notes = [note({ title: "" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the editor when another note is selected", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
||||
});
|
||||
|
||||
it("saves on blur, not on every keystroke", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "edited" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "edited" }),
|
||||
));
|
||||
});
|
||||
|
||||
it("does not save on blur when nothing changed", async () => {
|
||||
// Clicking through notes to read them must not write the file.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.blur(screen.getByLabelText("Note body"));
|
||||
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands the live editor text to the send button, not the last saved copy", () => {
|
||||
// Sending what is on screen is the whole contract: no transform on the way
|
||||
// out except the newline substitution.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
||||
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||
});
|
||||
|
||||
it("asks the hook to delete the selected note", async () => {
|
||||
// Only the call: `useNotes` is mocked here and the mocked list never
|
||||
// changes, so nothing in this file can exercise what the panel selects
|
||||
// afterwards. The fallback is covered against the real hook in
|
||||
// NotesPanel.shared.test.tsx.
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteEditor from "./NoteEditor";
|
||||
import Button from "../ui/Button";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const UNTITLED = "Untitled note";
|
||||
|
||||
/**
|
||||
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||
* two cannot drift into different behaviour.
|
||||
*
|
||||
* Master/detail: titles beside the editor when there is room, stacked above it
|
||||
* when there is not. That is a **container** query, not a viewport one, because
|
||||
* the two surfaces differ in width while sharing a viewport — the dock opens at
|
||||
* 352px and the tab is the width of the main area. A `md:` breakpoint would
|
||||
* read the window and give both the same answer, which is the wrong answer for
|
||||
* one of them.
|
||||
*
|
||||
* The threshold is arithmetic, not taste: side by side needs the 192px list,
|
||||
* plus an editor wide enough for its own action row (~280px), plus the divider.
|
||||
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
|
||||
* first stop that clears it.
|
||||
*
|
||||
* The editor holds draft text locally and commits on blur, which is how every
|
||||
* other editable field in the app behaves (`ClaudeInstructionsEditor`, the
|
||||
* Config tab).
|
||||
*/
|
||||
export default function NotesPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
<SaveIndicator state={saveState} />
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
No notes yet. Keep reminders here, and send any of them straight to a
|
||||
running Claude session.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col @lg:flex-row">
|
||||
{/* Stacked: a capped strip of titles above the editor, so the note
|
||||
being written keeps most of the height. Side by side: a full-height
|
||||
column of the fixed width the editor's arithmetic assumes. */}
|
||||
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||
selected?.id === n.id
|
||||
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex-1 min-w-0">
|
||||
{selected && (
|
||||
<NoteEditor
|
||||
projectId={projectId}
|
||||
title={title}
|
||||
body={body}
|
||||
onTitleChange={setTitle}
|
||||
onBodyChange={setBody}
|
||||
onCommit={commit}
|
||||
onDelete={() => void deleteNote(selected.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
const sendInput = vi.fn(async () => {});
|
||||
let sessions: TerminalSession[] = [];
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({ sessions, sendInput }),
|
||||
}));
|
||||
|
||||
const setActiveTabKey = vi.fn();
|
||||
const requestTerminalFocus = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
let projects: Project[] = [];
|
||||
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) =>
|
||||
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||
{
|
||||
getState: () => ({
|
||||
projects,
|
||||
setActiveTabKey,
|
||||
requestTerminalFocus,
|
||||
pushToast,
|
||||
}),
|
||||
},
|
||||
),
|
||||
terminalTabKey: (id: string) => `term:${id}`,
|
||||
}));
|
||||
|
||||
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessions = [];
|
||||
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
||||
});
|
||||
|
||||
describe("SendToAgentButton", () => {
|
||||
// Unavailable, not `disabled`: the reason a note cannot be sent is the whole
|
||||
// content of these states, and native `disabled` announces it to nobody.
|
||||
it("says why it cannot send when the project has no running session", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(
|
||||
"No running Claude session for this project",
|
||||
);
|
||||
});
|
||||
|
||||
it("says why it cannot send an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAccessibleDescription("Nothing to send — this note is empty");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session belongs to another project", () => {
|
||||
sessions = [session({ projectId: "other" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session is a bash tab", () => {
|
||||
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
||||
// shell is never a target.
|
||||
sessions = [session({ sessionType: "bash" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
|
||||
// guard this swap would turn a greyed-out button into a live one.
|
||||
it("sends nothing when activated while unavailable", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
|
||||
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
|
||||
});
|
||||
|
||||
it("focuses the terminal it sent to, so the user watches it land", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
|
||||
});
|
||||
|
||||
it("offers a menu of display names when several sessions are open", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
projects = [
|
||||
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
|
||||
];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
|
||||
});
|
||||
|
||||
it("reports a failed send rather than looking like it worked", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("session closed"));
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does nothing for an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the session menu upward when it sits at the foot of the dock", async () => {
|
||||
sessions = [session({ id: "s1" }), session({ id: "s2" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
// Anchored to the button's top edge, not below it: the dock clips its own
|
||||
// overflow, so a downward menu at the bottom edge is invisible.
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||
});
|
||||
|
||||
// Switching to the tab is not enough. When the dock is open beside the
|
||||
// terminal it sends to, that terminal is already the active tab, so
|
||||
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||
// this button, one click short of the Enter the user came to press.
|
||||
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||
});
|
||||
|
||||
it("leaves focus alone when the send failed", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("focuses the session picked from the menu, not the first one", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState, terminalTabKey } from "../../store/appState";
|
||||
import { toClaudePayload } from "../../lib/claudeInput";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
body: string;
|
||||
/**
|
||||
* Open the session menu above the button instead of below. The dock puts
|
||||
* this at its foot, and the dock clips its own overflow, so a downward menu
|
||||
* there is drawn outside the panel and never seen.
|
||||
*/
|
||||
dropUp?: boolean;
|
||||
/** Fill the row. The dock's send bar is the width of the dock. */
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a note into a running Claude session's prompt.
|
||||
*
|
||||
* Three behaviours by target count: none disables the button, one sends
|
||||
* straight there, several ask which. It never guesses — the note goes to a
|
||||
* session the user named, or to the only one there is.
|
||||
*
|
||||
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
|
||||
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
|
||||
*/
|
||||
export default function SendToAgentButton({
|
||||
projectId,
|
||||
body,
|
||||
dropUp = false,
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const { sessions, sendInput } = useTerminal();
|
||||
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
|
||||
useAppState(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
requestTerminalFocus: s.requestTerminalFocus,
|
||||
pushToast: s.pushToast,
|
||||
})),
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
sessions.filter(
|
||||
(s) => s.projectId === projectId && s.sessionType === "claude",
|
||||
),
|
||||
[sessions, projectId],
|
||||
);
|
||||
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const hasBody = body.trim().length > 0;
|
||||
const unavailable = targets.length === 0 || !hasBody;
|
||||
|
||||
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const send = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setMenuOpen(false);
|
||||
try {
|
||||
// No trailing CR: the note lands in the prompt and the user presses
|
||||
// Enter. Newlines become ESC+CR so it arrives as one message rather
|
||||
// than one prompt per line.
|
||||
await sendInput(sessionId, toClaudePayload(body));
|
||||
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||
// text still went.
|
||||
setActiveTabKey(terminalTabKey(sessionId));
|
||||
// Switching tabs is not the same as taking focus, and when the dock is
|
||||
// open beside the terminal it just sent to, that tab is already the
|
||||
// active one — so nothing above moves the caret off this button. The
|
||||
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||
requestTerminalFocus(sessionId);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not send the note to the agent",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
// The target is resolved at click time and pinned for the whole send, the
|
||||
// hazard `useSTT` guards against by capturing its session at record start:
|
||||
// the list can change while the request is in flight.
|
||||
if (targets.length === 1) {
|
||||
void send(targets[0].id);
|
||||
return;
|
||||
}
|
||||
setMenuOpen((open) => !open);
|
||||
}, [targets, send]);
|
||||
|
||||
const title = !hasBody
|
||||
? "Nothing to send — this note is empty"
|
||||
: targets.length === 0
|
||||
? "No running Claude session for this project"
|
||||
: "Put this note into the agent's prompt (you press Enter)";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={fullWidth ? "md" : "sm"}
|
||||
className={fullWidth ? "w-full" : ""}
|
||||
// Not `disabled`: every one of these reasons is information, and
|
||||
// `disabled` takes the button — reason and all — out of the
|
||||
// accessibility tree. `Button` guards the click for us.
|
||||
unavailable={unavailable}
|
||||
unavailableReason={title}
|
||||
onClick={onClick}
|
||||
aria-haspopup={targets.length > 1 ? "menu" : undefined}
|
||||
aria-expanded={targets.length > 1 ? menuOpen : undefined}
|
||||
title={title}
|
||||
>
|
||||
Send to agent
|
||||
</Button>
|
||||
{menuOpen && targets.length > 1 && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
|
||||
dropUp ? "bottom-full mb-1" : "mt-1"
|
||||
}`}
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{targets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => void send(s.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
{sessionDisplayName(s, project)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Draft text for the note being edited, committed when a field loses focus.
|
||||
*
|
||||
* This is the half the dock and the tab must never disagree on, so it lives
|
||||
* here rather than in either layout. The two surfaces differ in how they show
|
||||
* notes; they must not differ in when a keystroke becomes a save.
|
||||
*
|
||||
* The draft is "untouched" exactly while it still matches what was last copied
|
||||
* out of the store, which is what lets an edit made on the *other* surface
|
||||
* reach this one's editor without ever discarding half-typed text.
|
||||
*/
|
||||
export function useNoteDraft(
|
||||
selected: Note | null,
|
||||
saveNote: (note: Note) => Promise<unknown>,
|
||||
) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||
id: null,
|
||||
title: "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
// Re-seed on a change of note, and on a change to the *stored* text of the
|
||||
// note already open — the second case is the dock and the tab showing one
|
||||
// project at once.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
seeded.current = { id: null, title: "", body: "" };
|
||||
setTitle("");
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
const untouched =
|
||||
title === seeded.current.title && body === seeded.current.body;
|
||||
if (seeded.current.id !== selected.id || untouched) {
|
||||
seeded.current = {
|
||||
id: selected.id,
|
||||
title: selected.title,
|
||||
body: selected.body,
|
||||
};
|
||||
setTitle(selected.title);
|
||||
setBody(selected.body);
|
||||
}
|
||||
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const commit = () => {
|
||||
if (!selected) return;
|
||||
// Reading is not editing: clicking through notes must not rewrite the file.
|
||||
if (title === selected.title && body === selected.body) return;
|
||||
// Mark the draft as matching what was just committed, so the store update
|
||||
// this save produces reads as "no change" rather than as a stale re-seed.
|
||||
seeded.current = { id: selected.id, title, body };
|
||||
void saveNote({ ...selected, title, body });
|
||||
};
|
||||
|
||||
return { title, body, setTitle, setBody, commit };
|
||||
}
|
||||