Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 | ||
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 | ||
|
|
1a79852f65 | ||
|
|
68b73a9102 | ||
|
|
d09e2a2743 | ||
|
|
4371c9f03e | ||
|
|
eead748222 | ||
|
|
2c9482a67d | ||
|
|
88ffb4744a | ||
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 | ||
|
|
168b61d632 | ||
|
|
47960e46df | ||
|
|
73dfaf5785 | ||
|
|
01fd38bc4b | ||
|
|
00128f9b1a | ||
|
|
39934299f9 | ||
|
|
c6086b0ab3 | ||
|
|
f7db4323be | ||
|
|
ed91423666 | ||
|
|
6a8972980d | ||
|
|
7bbb699e4e | ||
|
|
5df3e7996d | ||
|
|
5d4d5d37df | ||
|
|
bb1c7696f9 | ||
|
|
f2a84c18f9 | ||
|
|
b49dddab45 | ||
|
|
dcd2dfe5a3 | ||
|
|
6d27f924ff | ||
|
|
e70a40507c | ||
|
|
5926a52ff6 | ||
|
|
42ef1865cc | ||
|
|
4f6c012071 | ||
|
|
6b8d43414d | ||
|
|
1768240861 | ||
|
|
7e1f8df1ff | ||
|
|
a76f2c0a17 | ||
|
|
17f031a5d7 | ||
|
|
5fba7d6d35 | ||
|
|
433afa5a49 | ||
|
|
fcea506dce | ||
|
|
6abc7f27a4 | ||
|
|
2b6501d8e5 | ||
|
|
3329e07d3d | ||
|
|
092972fe92 | ||
|
|
ae3ca8cda4 | ||
|
|
d6f065a2b6 | ||
|
|
0003793abb | ||
|
|
2b9bf56f25 | ||
|
|
611f67cca7 | ||
|
|
77ef2291d7 | ||
|
|
1c834a0b08 | ||
|
|
0a022dfcf0 | ||
|
|
bb41275cea | ||
|
|
2ca86bb5d8 | ||
|
|
df6d2f1ca4 | ||
|
|
dacc1157ec | ||
|
|
dd2894cc60 | ||
|
|
22d142c70d | ||
|
|
15e05e2197 | ||
|
|
75cace7dde | ||
|
|
24590546e3 | ||
|
|
d971326e4e | ||
|
|
48d0c3249a | ||
|
|
5b96ad4823 | ||
|
|
dcb13d23ea | ||
|
|
7a8bbcbef7 | ||
|
|
3bd3caa101 | ||
|
|
5dd1ab5217 | ||
|
|
92d64cf252 | ||
|
|
ab2c75d0b2 | ||
|
|
00937745f7 | ||
|
|
01e72e4785 | ||
|
|
2b35aa8c16 | ||
|
|
65a3d4eb29 | ||
|
|
2b2d9da606 | ||
|
|
3741e0fef5 | ||
|
|
0e6566d903 | ||
|
|
84a67fcd0d | ||
|
|
f3cc1c4c17 | ||
|
|
7265f55f27 | ||
|
|
88f2e73474 | ||
|
|
fa4940dd7d | ||
|
|
9027fa9ad4 | ||
|
|
be37723c38 | ||
|
|
5f990dd28b | ||
|
|
4df59da2d8 | ||
|
|
a72406f0d8 | ||
|
|
9b2f4fe79f | ||
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b | ||
|
|
e379c58684 | ||
|
|
ab747ce53d | ||
|
|
85ea3956e8 | ||
|
|
5bd80a05bc | ||
|
|
f239fa1c82 | ||
|
|
5b18ce804f | ||
|
|
63f3c54b95 | ||
|
|
f68d9c5788 | ||
|
|
bd72781482 | ||
|
|
1207a21aae | ||
|
|
a41d93ea46 | ||
|
|
d73096c937 | ||
|
|
57b6b71772 | ||
|
|
77567ac2ae | ||
|
|
247f03b48c | ||
|
|
31c73adb13 | ||
|
|
4fdfed7955 | ||
|
|
7a5823cb2b | ||
|
|
a5bcc462a7 | ||
|
|
c3f92674b1 | ||
|
|
584fcdd837 | ||
|
|
2c014fd752 | ||
|
|
43e9959e40 | ||
|
|
e05156fd0e | ||
|
|
aca6c49e3c | ||
|
|
0aa8315514 | ||
|
|
29fd7de909 | ||
|
|
fdc161fd9c | ||
|
|
98a6c8fd56 | ||
|
|
763af91042 | ||
|
|
c71e54a35f | ||
|
|
03384409e7 | ||
|
|
b41077e799 | ||
|
|
c6bb7fdf1d | ||
|
|
704d3b8f79 | ||
|
|
ca0f944712 | ||
|
|
2de00b3c55 | ||
|
|
eb1324cb16 | ||
|
|
d42b741337 | ||
|
|
cc5f691677 | ||
|
|
7d00390e1f | ||
|
|
cf3b021c72 | ||
|
|
d95ba54a69 | ||
|
|
01a2f6aec8 | ||
|
|
f68d10d5c2 | ||
|
|
0ac4e5030c | ||
|
|
d0bb631d4d | ||
|
|
657c61939f | ||
|
|
401e28a658 | ||
|
|
7c39e3cf11 | ||
|
|
ccdfc52dce | ||
|
|
2e661979ea | ||
|
|
59d89bcd1b | ||
|
|
26adccce5b | ||
|
|
876ba8a8fc | ||
|
|
5cd528a4ef | ||
|
|
c3fc029b1d | ||
|
|
dc253e8da0 | ||
|
|
3e2e3f231b | ||
|
|
01a8f5c503 | ||
|
|
0945e21eb1 | ||
|
|
d65872dc94 | ||
|
|
edf0698774 | ||
|
|
84e0bdf7b4 | ||
|
|
10e689eaa6 | ||
|
|
d07dcdfea9 | ||
|
|
424ab04ca8 | ||
|
|
1716fb5c82 | ||
|
|
da7b7b9bd5 | ||
|
|
3d2d979197 | ||
|
|
de2752557d | ||
|
|
9221c25474 | ||
|
|
997e1ab3a9 | ||
|
|
2be6b9d9a8 | ||
|
|
ebfe1f11a6 | ||
|
|
7f8102985e | ||
|
|
1a5fbd6be4 | ||
|
|
2fa6abeae0 | ||
|
|
5b1c801cf1 | ||
|
|
9b78b4bc62 | ||
|
|
7acc8b8d39 | ||
|
|
7840bddbb4 | ||
|
|
4588bdf40c | ||
|
|
b607cf3681 | ||
|
|
21a85dc977 | ||
|
|
272eb28863 | ||
|
|
1ef6efca9f | ||
|
|
5974347913 | ||
|
|
805f815876 | ||
|
|
5360f22b65 | ||
|
|
0316234329 | ||
|
|
ee68cc820c | ||
|
|
7f6655fbcf | ||
|
|
b907ad0239 | ||
|
|
de1d809de5 | ||
|
|
3c7852544b | ||
|
|
ddf44d97e5 | ||
|
|
d60124f1bd | ||
|
|
4f23951379 | ||
|
|
d6ac3ae6c6 | ||
|
|
ef67b447b3 | ||
|
|
15b03173a5 | ||
|
|
a0b4dca0bd | ||
|
|
17c5d699f9 | ||
|
|
e62af502d3 | ||
|
|
3e9053946f | ||
|
|
3bbd7fd55f | ||
|
|
49d09e4447 | ||
|
|
702ebb7247 | ||
|
|
caf3e26816 | ||
|
|
765ba91d7b | ||
|
|
532de77927 | ||
|
|
8301fd3690 | ||
|
|
2dffef0767 | ||
|
|
57a7cee544 | ||
|
|
6369f7e0a8 | ||
|
|
9ee0d34c19 | ||
|
|
922543cc04 | ||
|
|
13038989b8 | ||
|
|
b55de8d75e | ||
|
|
8512ca615d | ||
|
|
ebae39026f | ||
|
|
d34e8e2c6d | ||
|
|
3935104cb5 | ||
|
|
b17c759bd6 | ||
|
|
bab1df1c57 | ||
|
|
b952b8e8de | ||
|
|
d7d7a83aec | ||
|
|
879322bc9a | ||
|
|
ecaa42fa77 | ||
|
|
280358166a | ||
|
|
4732feb33e | ||
|
|
5977024953 | ||
|
|
27007b90e3 | ||
|
|
38e65619e9 | ||
|
|
d2c1c2108a | ||
|
|
cc163e6650 | ||
|
|
38082059a5 | ||
|
|
beae0942a1 | ||
|
|
6b49981b3a | ||
|
|
b46b392a9a | ||
|
|
4889dd974f | ||
|
|
b6fd8a557e | ||
|
|
93deab68a7 | ||
|
|
2dce2993cc | ||
|
|
e482452ffd | ||
|
|
8c710fc7bf | ||
|
|
b7585420ef | ||
|
|
bf8ef3dba1 | ||
|
|
418afe00ed | ||
|
|
ab16ac11e7 | ||
|
|
429acd2fb5 | ||
|
|
c853f2676d | ||
|
|
090aad6bc6 | ||
|
|
c023d80c86 | ||
|
|
33f02e65c0 | ||
|
|
c5e28f9caa | ||
|
|
86176d8830 | ||
|
|
58a10c65e9 | ||
|
|
d56c6e3845 | ||
|
|
574fca633a | ||
|
|
e07c0e6150 | ||
|
|
20a07c84f2 | ||
|
|
625d48a6ed | ||
|
|
2ddc705925 | ||
|
|
1aced2d860 | ||
|
|
652e451afe | ||
|
|
eb86aa95b7 | ||
|
|
3228e6cdd7 | ||
|
|
3344ce1cbf | ||
|
|
d642cc64de | ||
|
|
e3502876eb | ||
|
|
4f41f0d98b | ||
|
|
c9dc232fc4 | ||
|
|
2d4fce935f | ||
|
|
e739f6aaff | ||
|
|
550159fc63 | ||
|
|
e3c874bc75 | ||
|
|
6cae0e7feb | ||
|
|
b566446b75 | ||
|
|
601a2db3cf | ||
|
|
b795e27251 | ||
|
|
19d4cbce27 | ||
|
|
946ea03956 | ||
|
|
ba4cb4176d | ||
|
|
4b56610ff5 | ||
|
|
db51abb970 | ||
|
|
d947824436 | ||
|
|
c2b21b794c | ||
|
|
40493ae284 | ||
|
|
2e81b52205 | ||
|
|
06be613e36 | ||
|
|
da078af73f | ||
|
|
01ea581f8a | ||
|
|
552aaebf16 | ||
|
|
c2736ace90 | ||
|
|
2ff270ebfe | ||
|
|
5a59fdb64b | ||
|
|
1ce5151e59 | ||
|
|
66ddc182c9 | ||
|
|
1524ec4a98 | ||
|
|
4721950eae | ||
|
|
fba4b9442c | ||
|
|
48f0e2f64c |
@@ -0,0 +1,84 @@
|
|||||||
|
name: Backfill Releases to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backfill:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Backfill all Gitea releases to GitHub
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_API: https://repo.anhonesthost.net/api/v1
|
||||||
|
GITEA_REPO: cybercovellc/triple-c
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "==> Fetching releases from Gitea..."
|
||||||
|
RELEASES=$(curl -sf \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_API/repos/$GITEA_REPO/releases?limit=50")
|
||||||
|
|
||||||
|
echo "$RELEASES" | jq -c '.[]' | while read release; do
|
||||||
|
TAG=$(echo "$release" | jq -r '.tag_name')
|
||||||
|
NAME=$(echo "$release" | jq -r '.name')
|
||||||
|
BODY=$(echo "$release" | jq -r '.body')
|
||||||
|
IS_PRERELEASE=$(echo "$release" | jq -r '.prerelease')
|
||||||
|
IS_DRAFT=$(echo "$release" | jq -r '.draft')
|
||||||
|
|
||||||
|
EXISTS=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG" \
|
||||||
|
-o /dev/null -w "%{http_code}" || true)
|
||||||
|
|
||||||
|
if [ "$EXISTS" = "200" ]; then
|
||||||
|
echo "==> Skipping $TAG (already exists on GitHub)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Creating release $TAG..."
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$TAG\",
|
||||||
|
\"name\": \"$NAME\",
|
||||||
|
\"body\": $(echo "$BODY" | jq -Rs .),
|
||||||
|
\"draft\": $IS_DRAFT,
|
||||||
|
\"prerelease\": $IS_PRERELEASE
|
||||||
|
}")
|
||||||
|
|
||||||
|
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
|
echo "$release" | jq -c '.assets[]?' | while read asset; do
|
||||||
|
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||||
|
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||||
|
|
||||||
|
echo " ==> Downloading $ASSET_NAME..."
|
||||||
|
DOWNLOAD_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
curl -sfL -o "/tmp/$ASSET_NAME" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$DOWNLOAD_URL"
|
||||||
|
|
||||||
|
echo " ==> Uploading $ASSET_NAME to GitHub..."
|
||||||
|
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@/tmp/$ASSET_NAME" \
|
||||||
|
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||||
|
|
||||||
|
echo " Uploaded: $ASSET_NAME"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Done: $TAG"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Backfill complete."
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
name: Build App (Preview)
|
||||||
|
|
||||||
|
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||||
|
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||||
|
# sync.
|
||||||
|
#
|
||||||
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||||
|
# 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
|
||||||
|
# KEEP_PREVIEWS (see Lifecycle).
|
||||||
|
#
|
||||||
|
# ## Why not workflow artifacts
|
||||||
|
#
|
||||||
|
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||||
|
#
|
||||||
|
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||||
|
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
|
||||||
|
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
|
||||||
|
# throws before making a single request. act_runner sets that variable to this
|
||||||
|
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
|
||||||
|
# the whole Tauri build had been paid for (run #265).
|
||||||
|
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
|
||||||
|
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
|
||||||
|
# `total_count: 0` and the run page shows nothing (verified on run #267).
|
||||||
|
# A build nobody can find is not a build.
|
||||||
|
#
|
||||||
|
# So previews publish the same way every other workflow here does: curl to the
|
||||||
|
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
|
||||||
|
#
|
||||||
|
# ## Lifecycle
|
||||||
|
#
|
||||||
|
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
||||||
|
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
||||||
|
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
||||||
|
# release list, and a manual cleanup sweeps any this workflow missed.
|
||||||
|
#
|
||||||
|
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
|
||||||
|
# pushed commit accumulates faster than anyone runs it. So the last job here
|
||||||
|
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||||
|
# release; the point of a preview is the build you are testing now.
|
||||||
|
#
|
||||||
|
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||||
|
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||||
|
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||||
|
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||||
|
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||||
|
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||||
|
# (never valid semver) already blocks it independently. (The previous
|
||||||
|
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||||
|
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||||
|
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||||
|
# since build-app.yml's inline mirror already does what it was meant to do
|
||||||
|
# for real releases. See triple-c#32.)
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
# How many preview releases survive a run, newest first — including the one
|
||||||
|
# just published.
|
||||||
|
KEEP_PREVIEWS: "2"
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Every push to an open PR: this *is* the branch's build check — it compiles
|
||||||
|
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
||||||
|
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
|
- ".gitea/workflows/build-app-preview.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compute-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
sha: ${{ steps.version.outputs.SHA }}
|
||||||
|
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||||
|
# The bundle version fields never see this — see "Set app version" in
|
||||||
|
# each build job — but it is baked into the binary as
|
||||||
|
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||||
|
# An installed preview otherwise reports the same bare number a
|
||||||
|
# production build would, indistinguishable in the About panel and to
|
||||||
|
# `check_for_updates`. See triple-c#32.
|
||||||
|
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
|
- name: Compute preview version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
# From the checkout, not from `gitea.sha`: on a pull_request event
|
||||||
|
# that variable can be the merge ref, which is not the commit anyone
|
||||||
|
# is testing and not something to hang a tag on.
|
||||||
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The patch number must be the same "one past the highest patch
|
||||||
|
# already used" build-app.yml computes for a real release — not a
|
||||||
|
# distance from the latest tag. It used to be
|
||||||
|
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||||
|
# own history section documents as broken for exactly this reason:
|
||||||
|
# it resets to zero on every tag cut, so previews went *backwards*
|
||||||
|
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||||
|
# a preview number from later colliding with a real release's.
|
||||||
|
#
|
||||||
|
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||||
|
# / `-win` suffixed ones a partially-published release can leave
|
||||||
|
# behind) means a preview built right before a release computes the
|
||||||
|
# exact number that release is about to take — e.g. `0.4.13` for
|
||||||
|
# both. That makes the two numerically *equal*, not "preview less
|
||||||
|
# than release" — plain semver ordering does not make a
|
||||||
|
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||||
|
# `check_for_updates` compares against the bare, stripped
|
||||||
|
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||||
|
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||||
|
# check, which relaxes that one comparison to `>=` specifically so
|
||||||
|
# "a release exists at my own number" reads as an update. See
|
||||||
|
# triple-c#32.
|
||||||
|
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
|
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||||
|
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||||
|
# HEAD can be a commit a release was already cut from. Without this,
|
||||||
|
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||||
|
# that release — and produce exactly the "preview outranks
|
||||||
|
# production" failure triple-c#32 was filed over, just reintroduced
|
||||||
|
# through the manual-dispatch door instead of the automatic one.
|
||||||
|
EXISTING=$(git tag --points-at HEAD \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||||
|
PATCH="${EXISTING}"
|
||||||
|
elif [ -n "$HIGHEST" ]; then
|
||||||
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||||
|
PATCH=$((HIGHEST + 1))
|
||||||
|
else
|
||||||
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
|
PATCH=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUFFIX="preview.${SHORT_SHA}"
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
|
# One release, created once. The three build jobs run concurrently, so
|
||||||
|
# get-or-create in each of them would race on the same tag: whoever loses gets
|
||||||
|
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
|
||||||
|
# still reported success. Creating it in a job they all depend on removes the
|
||||||
|
# race rather than handling it.
|
||||||
|
create-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
||||||
|
tag: ${{ steps.release.outputs.TAG }}
|
||||||
|
steps:
|
||||||
|
- name: Create the preview release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
|
SHA: ${{ needs.compute-version.outputs.sha }}
|
||||||
|
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="preview-${VERSION##*.}"
|
||||||
|
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Idempotent: re-dispatching the same commit must update the existing
|
||||||
|
# release rather than fail on the duplicate tag.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200) echo "Release ${TAG} already exists, reusing" ;;
|
||||||
|
404)
|
||||||
|
echo "Creating release ${TAG}"
|
||||||
|
# prerelease: true keeps it off "latest" — this is a branch build,
|
||||||
|
# not something anyone should install by accident.
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "Failed to parse release id; response was:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
# Tauri / Cargo require a strict semver; strip the preview suffix for
|
||||||
|
# the bundle version but keep it in the artifact filename.
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- 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 \
|
||||||
|
patchelf \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
|
- 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` — 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
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||||
|
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||||
|
# no room for a suffix), so this is the only place a preview build
|
||||||
|
# can still tell itself apart from a production one. See
|
||||||
|
# triple-c#32.
|
||||||
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
# 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
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
|
- name: Upload Linux bundles to the preview release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No Linux bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [compute-version, create-release]
|
||||||
|
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/')
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
brew install node@22
|
||||||
|
brew link --overwrite node@22
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
# `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
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app (universal)
|
||||||
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||||
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
|
- name: Upload macOS bundles to the preview release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No macOS bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: [compute-version, create-release]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: cmd
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$raw = "${{ needs.compute-version.outputs.version }}"
|
||||||
|
$version = $raw.Split('-')[0]
|
||||||
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
where rustup >nul 2>&1 && (
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
) || (
|
||||||
|
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
|
||||||
|
rustup-init.exe -y --default-toolchain stable
|
||||||
|
del rustup-init.exe
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
where node >nul 2>&1 && (
|
||||||
|
node --version
|
||||||
|
) || (
|
||||||
|
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
||||||
|
msiexec /i node-install.msi /quiet /norestart
|
||||||
|
del node-install.msi
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Verify tools
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Install Tauri CLI via cargo
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
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: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm config set os win32
|
||||||
|
npm config list
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
if exist node_modules rmdir /s /q node_modules
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
# 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 --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
mkdir artifacts
|
||||||
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
||||||
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
|
dir artifacts\
|
||||||
|
|
||||||
|
# PowerShell, because this job's default shell is cmd. Same
|
||||||
|
# delete-then-upload shape as the other two.
|
||||||
|
- name: Upload Windows bundles to the preview release
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
|
$files = @(Get-ChildItem -File -Path artifacts\*)
|
||||||
|
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
||||||
|
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Replacing existing asset $name"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the preview list short. Runs after the builds and only if all three
|
||||||
|
# succeeded: a half-published run must not be what evicts a good older build.
|
||||||
|
prune-previews:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [create-release, build-linux, build-macos, build-windows]
|
||||||
|
steps:
|
||||||
|
- name: Delete all but the newest preview releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
||||||
|
|
||||||
|
# Newest first by creation time, `preview-` only, and never the one
|
||||||
|
# this run just published — a clock skew must not delete it.
|
||||||
|
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
||||||
|
previews = [r for r in json.load(open("releases.json"))
|
||||||
|
if r["tag_name"].startswith("preview-")]
|
||||||
|
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
|
for r in previews[keep:]:
|
||||||
|
if r["tag_name"] != keep_tag:
|
||||||
|
print(r["id"], r["tag_name"])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "${DOOMED}" ]; then
|
||||||
|
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${DOOMED}" | while read -r ID TAG; do
|
||||||
|
[ -z "${ID}" ] && continue
|
||||||
|
echo "Deleting ${TAG} (id ${ID})"
|
||||||
|
# Best effort: a preview someone deleted by hand mid-run is not a
|
||||||
|
# reason to fail a build that otherwise succeeded.
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
||||||
|
done
|
||||||
@@ -5,37 +5,134 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "app/**"
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
pull_request:
|
- "scripts/windows-*.ps1"
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "app/**"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||||
|
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||||
|
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||||
|
# the same three and publishes them. Six OS builds per push, one set of which
|
||||||
|
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
compute-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Compute version
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
|
- name: Compute version from VERSION file and tags
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
VERSION="0.1.${COMMIT_COUNT}"
|
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||||
|
|
||||||
|
# The patch number is **one past the highest patch already used**, and
|
||||||
|
# never a distance.
|
||||||
|
#
|
||||||
|
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
||||||
|
# not a counter at all: it measures how far HEAD has drifted from
|
||||||
|
# whichever tag sorts highest, and that resets to zero every time a
|
||||||
|
# tag is cut. The published history is the proof — each of these is
|
||||||
|
# exactly what the old formula returned at the time:
|
||||||
|
#
|
||||||
|
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
||||||
|
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
||||||
|
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
||||||
|
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
||||||
|
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
||||||
|
#
|
||||||
|
# Reusing a version is worse than failing to publish one: the macOS
|
||||||
|
# and Windows steps replace assets in place, so a duplicate silently
|
||||||
|
# rewrote a release that had been public for three days. Monotonic
|
||||||
|
# numbering is what stops that at the source.
|
||||||
|
#
|
||||||
|
# Suffixed tags count too. `create-tag` is skipped when any platform
|
||||||
|
# job fails, so a run can publish v0.4.7-mac and never create the
|
||||||
|
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
||||||
|
# same number out twice.
|
||||||
|
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
|
# A re-run of a commit that already released must not mint a new
|
||||||
|
# version just because its own tag now exists.
|
||||||
|
EXISTING=$(git tag --points-at HEAD \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
|
||||||
|
PATCH="${EXISTING}"
|
||||||
|
elif [ -n "$HIGHEST" ]; then
|
||||||
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||||
|
PATCH=$((HIGHEST + 1))
|
||||||
|
else
|
||||||
|
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||||
|
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||||
|
# old fallback here counted every commit in the repository, which
|
||||||
|
# would have made the first 0.4 build 0.4.234.
|
||||||
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
|
PATCH=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Computed version: ${VERSION}"
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
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
|
||||||
|
echo "Node.js at: $(which node)"
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set app version
|
- name: Set app version
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
@@ -61,36 +158,257 @@ jobs:
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
- name: Rust cache
|
echo "Rust already installed: $(rustc --version)"
|
||||||
uses: swatinem/rust-cache@v2
|
rustup update stable
|
||||||
with:
|
rustup default stable
|
||||||
workspaces: "./app/src-tauri -> target"
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
- name: Install Node.js
|
fi
|
||||||
uses: actions/setup-node@v4
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
with:
|
rustc --version
|
||||||
node-version: "22"
|
cargo --version
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npm ci
|
run: |
|
||||||
|
# `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
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npx tauri --version || npm install @tauri-apps/cli
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npx tauri build
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
# 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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
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/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
ls -la artifacts/
|
||||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
|
||||||
|
# A green job that published nothing is the worst outcome available:
|
||||||
|
# the release exists, carries no AppImage, and nobody is told. The
|
||||||
|
# `|| true` above is there so a missing bundle does not mask the real
|
||||||
|
# error, which makes this check the thing that catches it.
|
||||||
|
shopt -s nullglob
|
||||||
|
collected=(artifacts/*)
|
||||||
|
if [ ${#collected[@]} -eq 0 ]; then
|
||||||
|
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||||
|
|
||||||
|
# Idempotent get-or-create, matching build-macos. This step used to
|
||||||
|
# POST /releases unconditionally: against a tag that already existed
|
||||||
|
# Gitea answered 409, the grep below found no id, and the run died
|
||||||
|
# with a bare "exitcode '1'" and not one line of output explaining
|
||||||
|
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
||||||
|
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
||||||
|
# pipefail so a failure cannot be stepped over.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200)
|
||||||
|
echo "Release ${TAG} already exists, reusing"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "Creating release ${TAG}"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
|
|
||||||
|
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
||||||
|
# Versions are monotonic now (see compute-version), so this can only
|
||||||
|
# ever be replacing an asset from a failed run of this same commit —
|
||||||
|
# never one belonging to an already-published version.
|
||||||
|
for file in artifacts/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||||
|
--max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The fixed tag every installed AppImage checks for updates. Separate
|
||||||
|
# from the versioned release above because the updater's URL must never
|
||||||
|
# move, and `releases/latest` does.
|
||||||
|
- name: Publish the Linux update channel
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
bash scripts/publish-update-channel.sh \
|
||||||
|
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
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/')
|
||||||
|
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Node.js not found, installing 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
brew install node@22
|
||||||
|
brew link --overwrite node@22
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${VERSION}"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
echo "Rust already installed: $(rustc --version)"
|
||||||
|
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"
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
# `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
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app (universal)
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
@@ -98,21 +416,72 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="v${{ steps.version.outputs.VERSION }}"
|
set -euo pipefail
|
||||||
# Create release
|
TAG="v${{ needs.compute-version.outputs.version }}-mac"
|
||||||
curl -s -X POST \
|
|
||||||
|
# Idempotent get-or-create. macOS upload has historically failed
|
||||||
|
# mid-stream (curl exit 92, exit 28), leaving the release record
|
||||||
|
# with empty assets. A naive POST /releases on the next run hits
|
||||||
|
# 409 from Gitea for the duplicate tag, the JSON parse below
|
||||||
|
# then yields an empty RELEASE_ID, and pipefail aborts with an
|
||||||
|
# opaque exit 1. Look the release up by tag first; create only
|
||||||
|
# if it doesn't exist; reuse the existing id otherwise.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200)
|
||||||
|
echo "Release ${TAG} already exists, reusing"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "Release ${TAG} not found, creating"
|
||||||
|
curl -fsS -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "Failed to parse release id; response was:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
# Upload each artifact
|
|
||||||
|
# Upload each artifact. If an asset with the same name already
|
||||||
|
# exists on the release (left over from a partial prior run),
|
||||||
|
# delete it first so the upload is replace-not-conflict.
|
||||||
|
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
|
||||||
|
# the macOS runner has hit, retries with backoff for transient
|
||||||
|
# drops, and -f so HTTP errors stop being silently swallowed.
|
||||||
for file in artifacts/*; do
|
for file in artifacts/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST \
|
curl -fsS --http1.1 \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||||
|
--max-time 600 \
|
||||||
|
-X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file}" \
|
--data-binary "@${file}" \
|
||||||
@@ -121,6 +490,7 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
needs: [compute-version]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -130,23 +500,86 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Compute version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
|
|
||||||
set "VERSION=0.1.%COMMIT_COUNT%"
|
|
||||||
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
|
||||||
echo Computed version: %VERSION%
|
|
||||||
|
|
||||||
- name: Set app version
|
- name: Set app version
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ steps.version.outputs.VERSION }}"
|
$version = "${{ needs.compute-version.outputs.version }}"
|
||||||
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
Write-Host "Patched version to $version"
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install MSVC C++ build tools
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
|
||||||
|
rem This job previously assumed a hand-provisioned runner; a runner
|
||||||
|
rem without them registers fine, advertises windows-latest, accepts the
|
||||||
|
rem job, downloads the whole crate graph and only then fails at link
|
||||||
|
rem time with "linker `link.exe` not found".
|
||||||
|
rem
|
||||||
|
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
|
||||||
|
rem installing is enough - no dev-shell activation needed here.
|
||||||
|
rem
|
||||||
|
rem Delayed expansion is required: %VAR% inside a parenthesised block
|
||||||
|
rem is substituted when the block is PARSED, not when it runs, so both
|
||||||
|
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
set "VCPATH="
|
||||||
|
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||||
|
if exist "%VSWHERE%" (
|
||||||
|
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
|
||||||
|
)
|
||||||
|
if defined VCPATH (
|
||||||
|
echo MSVC build tools already present at !VCPATH!
|
||||||
|
) else (
|
||||||
|
echo MSVC build tools not found - installing Visual Studio Build Tools
|
||||||
|
curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
|
||||||
|
"%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
|
||||||
|
set "VSEXIT=!ERRORLEVEL!"
|
||||||
|
del "%TEMP%\vs_BuildTools.exe" 2>nul
|
||||||
|
rem 3010 means installed, reboot pending - a success for our purposes.
|
||||||
|
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
|
||||||
|
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Visual Studio Build Tools installed
|
||||||
|
)
|
||||||
|
endlocal
|
||||||
|
|
||||||
|
- name: Work around WOW64 redirection for 32-bit bundlers
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
rem Tauri downloads its bundlers - candle.exe, light.exe and
|
||||||
|
rem makensis.exe - and every one of them is 32-bit. When the runner
|
||||||
|
rem runs as SYSTEM its %LOCALAPPDATA% is under
|
||||||
|
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
|
||||||
|
rem serves any 32-bit process reading System32 from SysWOW64 instead -
|
||||||
|
rem where those directories do not exist. The bundlers then cannot see
|
||||||
|
rem their own folder: candle exits 0x80131700 and makensis reports
|
||||||
|
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
|
||||||
|
rem only "failed to run candle.exe", which is why this is worth a
|
||||||
|
rem comment this long.
|
||||||
|
rem
|
||||||
|
rem Junctioning the SysWOW64 view onto the System32 originals makes the
|
||||||
|
rem redirected path resolve to the same files. A runner running as a
|
||||||
|
rem normal user has a profile outside System32 and skips all of this.
|
||||||
|
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
|
||||||
|
if errorlevel 1 goto skipwow
|
||||||
|
|
||||||
|
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||||
|
|
||||||
|
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
|
||||||
|
|
||||||
|
echo WOW64 junctions in place for the SYSTEM profile
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:skipwow
|
||||||
|
echo Runner profile is outside System32 - WOW64 junctions not needed
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
run: |
|
run: |
|
||||||
where rustup >nul 2>&1 && (
|
where rustup >nul 2>&1 && (
|
||||||
@@ -179,7 +612,11 @@ jobs:
|
|||||||
- name: Install Tauri CLI via cargo
|
- name: Install Tauri CLI via cargo
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
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
|
- name: Fix npm platform detection
|
||||||
run: |
|
run: |
|
||||||
@@ -200,35 +637,243 @@ jobs:
|
|||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
npm run build
|
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
|
- name: Build Tauri app
|
||||||
working-directory: ./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:
|
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: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
||||||
|
rem makensis.exe - is 32-bit. A runner running as SYSTEM has
|
||||||
|
rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
|
||||||
|
rem WOW64 redirection sends 32-bit processes reading System32 to
|
||||||
|
rem SysWOW64, so they cannot see their own directory: candle exits
|
||||||
|
rem 0x80131700 and makensis reports "Unable to start child process,
|
||||||
|
rem error 0x2".
|
||||||
|
rem
|
||||||
|
rem The build VM carries junctions from the SysWOW64 view of
|
||||||
|
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 --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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
mkdir artifacts
|
mkdir artifacts
|
||||||
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1
|
||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
$ErrorActionPreference = "Stop"
|
||||||
echo Creating release %TAG%...
|
$tag = "v$env:VERSION-win"
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
:found
|
|
||||||
echo Release ID: %RELEASE_ID%
|
# Idempotent get-or-create. The old cmd-batch version swallowed
|
||||||
for %%f in (artifacts\*) do (
|
# curl errors and parsed the release id with findstr, so a 409 on
|
||||||
echo Uploading %%~nxf...
|
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
|
# a malformed .../releases//assets URL while the step still reported
|
||||||
)
|
# success. Look the release up by tag first; create only on 404.
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
|
||||||
|
Write-Host "Release $tag already exists, reusing"
|
||||||
|
} catch {
|
||||||
|
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||||
|
Write-Host "Release $tag not found, creating"
|
||||||
|
$body = @{
|
||||||
|
tag_name = $tag
|
||||||
|
name = "Triple-C v$env:VERSION (Windows)"
|
||||||
|
body = "Automated build from commit $env:COMMIT_SHA"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$release = Invoke-RestMethod -Method Post -Headers $headers `
|
||||||
|
-ContentType "application/json" -Body $body -Uri "$api/releases"
|
||||||
|
} else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = $release.id
|
||||||
|
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
|
||||||
|
Write-Host "Release ID: $releaseId"
|
||||||
|
|
||||||
|
# Upload each artifact. Delete any same-named asset left over from a
|
||||||
|
# partial prior run first, so the upload replaces rather than 409s.
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
|
||||||
|
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Deleting existing asset $name (id $($dupe.id))"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
create-tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create version tag
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
echo "Creating tag ${TAG}..."
|
||||||
|
|
||||||
|
# Create annotated tag via Gitea API
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||||
|
|
||||||
|
echo "Tag ${TAG} created successfully"
|
||||||
|
|
||||||
|
sync-to-github:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts from Gitea releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# Download assets from all 3 platform releases
|
||||||
|
for TAG_SUFFIX in "" "-mac" "-win"; do
|
||||||
|
TAG="v${VERSION}${TAG_SUFFIX}"
|
||||||
|
echo "==> Fetching assets for release ${TAG}..."
|
||||||
|
|
||||||
|
RELEASE_JSON=$(curl -sf \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||||
|
|
||||||
|
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
|
||||||
|
[ -z "$NAME" ] && continue
|
||||||
|
echo " Downloading ${NAME}..."
|
||||||
|
curl -sfL \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-o "artifacts/${NAME}" \
|
||||||
|
"$URL"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> All downloaded artifacts:"
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Create GitHub release and upload artifacts
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
echo "==> Creating unified release ${TAG} on GitHub..."
|
||||||
|
|
||||||
|
# Delete existing release if present (idempotent re-runs)
|
||||||
|
EXISTING=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${TAG}\",
|
||||||
|
\"name\": \"Triple-C ${TAG}\",
|
||||||
|
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
echo "==> Upload URL: ${UPLOAD_URL}"
|
||||||
|
|
||||||
|
for file in artifacts/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
FILENAME=$(basename "$file")
|
||||||
|
MIME="application/octet-stream"
|
||||||
|
echo "==> Uploading ${FILENAME}..."
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: ${MIME}" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> GitHub release sync complete."
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build STT Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "stt-container/**"
|
||||||
|
- ".gitea/workflows/build-stt.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "stt-container/**"
|
||||||
|
- ".gitea/workflows/build-stt.yml"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: repo.anhonesthost.net
|
||||||
|
IMAGE_NAME: cybercovellc/triple-c/triple-c-stt
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-stt-container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
|
- name: Build and push STT container image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./stt-container
|
||||||
|
file: ./stt-container/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ gitea.event_name == 'push' }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
ghcr.io/shadowdao/triple-c-stt:latest
|
||||||
|
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -5,10 +5,12 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "container/**"
|
- "container/**"
|
||||||
|
- ".gitea/workflows/build.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "container/**"
|
- "container/**"
|
||||||
|
- ".gitea/workflows/build.yml"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: repo.anhonesthost.net
|
REGISTRY: repo.anhonesthost.net
|
||||||
@@ -21,8 +23,32 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -31,14 +57,40 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./container
|
context: ./container
|
||||||
file: ./container/Dockerfile
|
file: ./container/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: ${{ gitea.event_name == 'push' }}
|
push: ${{ gitea.event_name == 'push' }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ 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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,ignore-error=true
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
name: Cleanup Old Releases
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
keep_versions:
|
||||||
|
description: "Number of recent versions to keep (each version has 3 releases: Linux, macOS, Windows)"
|
||||||
|
required: true
|
||||||
|
default: "5"
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run - list what would be deleted without actually deleting"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- "true"
|
||||||
|
- "false"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Cleanup old releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
KEEP_VERSIONS: ${{ gitea.event.inputs.keep_versions }}
|
||||||
|
DRY_RUN: ${{ gitea.event.inputs.dry_run }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "==> Configuration"
|
||||||
|
echo " Keep versions: ${KEEP_VERSIONS}"
|
||||||
|
echo " Dry run: ${DRY_RUN}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Fetch all Gitea releases (paginated) ──
|
||||||
|
ALL_RELEASES="[]"
|
||||||
|
PAGE=1
|
||||||
|
while true; do
|
||||||
|
BATCH=$(curl -sf \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50&page=${PAGE}")
|
||||||
|
COUNT=$(echo "$BATCH" | jq 'length')
|
||||||
|
[ "$COUNT" -eq 0 ] && break
|
||||||
|
ALL_RELEASES=$(echo "$ALL_RELEASES" "$BATCH" | jq -s '.[0] + .[1]')
|
||||||
|
PAGE=$((PAGE + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(echo "$ALL_RELEASES" | jq 'length')
|
||||||
|
echo "==> Found ${TOTAL} total Gitea releases"
|
||||||
|
|
||||||
|
# ── Extract unique version numbers and sort them ──
|
||||||
|
# Tags are like: v0.2.26, v0.2.26-mac, v0.2.26-win, build-xxx
|
||||||
|
# Extract the base version (strip -mac, -win suffixes)
|
||||||
|
VERSIONS=$(echo "$ALL_RELEASES" | jq -r '.[].tag_name' \
|
||||||
|
| sed 's/-mac$//' | sed 's/-win$//' \
|
||||||
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
|
| sort -t. -k1,1V -k2,2n -k3,3n \
|
||||||
|
| uniq)
|
||||||
|
|
||||||
|
VERSION_COUNT=$(echo "$VERSIONS" | wc -l)
|
||||||
|
echo "==> Found ${VERSION_COUNT} unique versions"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Determine which versions to keep and which to delete ──
|
||||||
|
KEEP=$(echo "$VERSIONS" | tail -n "${KEEP_VERSIONS}")
|
||||||
|
DELETE=$(echo "$VERSIONS" | head -n -"${KEEP_VERSIONS}")
|
||||||
|
|
||||||
|
DELETE_COUNT=$(echo "$DELETE" | grep -c . || true)
|
||||||
|
if [ "$DELETE_COUNT" -eq 0 ]; then
|
||||||
|
echo "==> Nothing to clean up. Only ${VERSION_COUNT} versions exist, keeping ${KEEP_VERSIONS}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Keeping ${KEEP_VERSIONS} most recent versions:"
|
||||||
|
echo "$KEEP" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo "==> Will delete ${DELETE_COUNT} older versions ($(echo "$DELETE" | head -1) through $(echo "$DELETE" | tail -1)):"
|
||||||
|
echo "$DELETE" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Delete releases ──
|
||||||
|
DELETED_GITEA=0
|
||||||
|
DELETED_GITHUB=0
|
||||||
|
DELETED_TAGS=0
|
||||||
|
|
||||||
|
for VERSION in $DELETE; do
|
||||||
|
# Each version can have up to 3 releases: base, -mac, -win
|
||||||
|
for SUFFIX in "" "-mac" "-win"; do
|
||||||
|
TAG="${VERSION}${SUFFIX}"
|
||||||
|
|
||||||
|
# Find the Gitea release ID for this tag
|
||||||
|
RELEASE_ID=$(echo "$ALL_RELEASES" | jq -r --arg tag "$TAG" '.[] | select(.tag_name == $tag) | .id // empty')
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete Gitea release: ${TAG} (id: ${RELEASE_ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting Gitea release: ${TAG} (id: ${RELEASE_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}" || echo " Warning: failed to delete Gitea release ${TAG}"
|
||||||
|
DELETED_GITEA=$((DELETED_GITEA + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete the Gitea tag
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete Gitea tag: ${TAG}"
|
||||||
|
else
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null && DELETED_TAGS=$((DELETED_TAGS + 1)) || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Delete the unified GitHub release (single tag per version, no suffix)
|
||||||
|
if [ -n "$GH_PAT" ]; then
|
||||||
|
GH_RELEASE=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${VERSION}" 2>/dev/null || echo "{}")
|
||||||
|
GH_RELEASE_ID=$(echo "$GH_RELEASE" | jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -n "$GH_RELEASE_ID" ]; then
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" || echo " Warning: failed to delete GitHub release ${VERSION}"
|
||||||
|
DELETED_GITHUB=$((DELETED_GITHUB + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete the GitHub tag
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete GitHub tag: ${VERSION}"
|
||||||
|
else
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${VERSION}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Also clean up any legacy non-semver releases (e.g., build-xxx) ──
|
||||||
|
LEGACY_RELEASES=$(echo "$ALL_RELEASES" | jq -r '.[] | select(.tag_name | test("^v[0-9]") | not) | "\(.id) \(.tag_name)"')
|
||||||
|
LEGACY_COUNT=$(echo "$LEGACY_RELEASES" | grep -c . || true)
|
||||||
|
|
||||||
|
if [ "$LEGACY_COUNT" -gt 0 ]; then
|
||||||
|
echo "==> Found ${LEGACY_COUNT} legacy (non-versioned) releases to clean up:"
|
||||||
|
echo "$LEGACY_RELEASES" | while read -r ID TAG; do
|
||||||
|
[ -z "$ID" ] && continue
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete legacy release: ${TAG} (id: ${ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting legacy release: ${TAG} (id: ${ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || echo " Warning: failed to delete ${TAG}"
|
||||||
|
# Delete the tag too
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null || true
|
||||||
|
DELETED_GITEA=$((DELETED_GITEA + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ──
|
||||||
|
echo "==> Cleanup complete"
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " Mode: DRY RUN (no changes made)"
|
||||||
|
echo " Would delete: ${DELETE_COUNT} versions (up to $((DELETE_COUNT * 3)) Gitea releases + GitHub releases)"
|
||||||
|
[ "$LEGACY_COUNT" -gt 0 ] && echo " Would also delete: ${LEGACY_COUNT} legacy releases"
|
||||||
|
else
|
||||||
|
echo " Gitea releases deleted: ${DELETED_GITEA}"
|
||||||
|
echo " GitHub releases deleted: ${DELETED_GITHUB}"
|
||||||
|
echo " Tags deleted: ${DELETED_TAGS}"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Secret Scan
|
||||||
|
|
||||||
|
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||||
|
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||||
|
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||||
|
# touching the wrong directory is not a scan.
|
||||||
|
#
|
||||||
|
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||||
|
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||||
|
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||||
|
# from inside a repository.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||||
|
# but depends on getting the range right across pushes, force-pushes,
|
||||||
|
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||||
|
# takes under half a second on this repository and cannot be evaded by
|
||||||
|
# arranging for the interesting commit to sit outside the window.
|
||||||
|
- name: Scan tracked files for credentials
|
||||||
|
run: sh scripts/scan-secrets.sh --tracked
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Refuse a commit that adds something shaped like a live credential.
|
||||||
|
#
|
||||||
|
# Installed by pointing git at this directory:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath .githooks
|
||||||
|
#
|
||||||
|
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||||
|
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||||
|
# a repo would then be enough to run its code. So this is opt-in on every
|
||||||
|
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||||
|
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||||
|
# hook exists to tell you in one second rather than in five minutes.
|
||||||
|
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
app/dist/
|
app/dist/
|
||||||
app/src-tauri/target/
|
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
|
Screenshot*.png
|
||||||
code-review.md
|
code-review.md
|
||||||
|
|
||||||
|
# Windows NTFS alternate-data-stream artifacts, created when files arrive
|
||||||
|
# through the WSL/host bind mount.
|
||||||
|
*:Zone.Identifier
|
||||||
|
|
||||||
|
# Local bug-report screenshots, same spirit as Screenshot*.png above.
|
||||||
|
screenshot_for_fix/
|
||||||
|
|
||||||
|
# Package files pulled in by ad-hoc verification runs.
|
||||||
|
*.deb
|
||||||
|
|
||||||
|
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||||
|
.code-signing/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Building Triple-C
|
# Building Triple-C
|
||||||
|
|
||||||
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux and Windows.
|
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux, macOS, and Windows.
|
||||||
|
|
||||||
## Prerequisites (All Platforms)
|
## Prerequisites (All Platforms)
|
||||||
|
|
||||||
@@ -71,13 +71,80 @@ npm ci
|
|||||||
npx tauri build
|
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/`:
|
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
### 1. Install prerequisites
|
||||||
|
|
||||||
|
- **Xcode Command Line Tools** — required for the C/C++ toolchain and system headers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
No additional system libraries are needed — macOS includes WebKit natively.
|
||||||
|
|
||||||
|
### 2. Install Rust targets (universal binary)
|
||||||
|
|
||||||
|
To build a universal binary that runs on both Apple Silicon and Intel Macs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install frontend dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build
|
||||||
|
|
||||||
|
For a universal binary (recommended for distribution):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
For the current architecture only (faster, for local development):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build artifacts are located in `app/src-tauri/target/universal-apple-darwin/release/bundle/` (or `target/release/bundle/` for single-arch builds):
|
||||||
|
|
||||||
| Format | Path |
|
| Format | Path |
|
||||||
|------------|-------------------------------|
|
|--------|------|
|
||||||
| AppImage | `appimage/*.AppImage` |
|
| DMG | `dmg/*.dmg` |
|
||||||
| Debian pkg | `deb/*.deb` |
|
| macOS App | `macos/*.app` |
|
||||||
| RPM pkg | `rpm/*.rpm` |
|
| macOS App (compressed) | `macos/*.app.tar.gz` |
|
||||||
|
|
||||||
|
> **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`
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,918 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Triple-C (Claude-Code-Container) is a Tauri v2 desktop application that sandboxes Claude Code inside Docker containers. It has two main parts: a React/TypeScript frontend, a Rust backend, and a Docker container image definition.
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
All frontend/tauri commands run from the `app/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm ci # Install dependencies (required first time)
|
||||||
|
npx tauri dev # Launch app in dev mode with hot reload (Vite on port 1420)
|
||||||
|
npx tauri build # Production build (outputs to src-tauri/target/release/bundle/)
|
||||||
|
npm run build # Frontend-only build (tsc + vite)
|
||||||
|
npm run test # Run Vitest once
|
||||||
|
npm run test:watch # Run Vitest in watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust backend is compiled automatically by `tauri dev`/`tauri build`. To check Rust independently:
|
||||||
|
```bash
|
||||||
|
cd app/src-tauri
|
||||||
|
cargo check # Type-check without full build
|
||||||
|
cargo build # Build Rust backend only
|
||||||
|
```
|
||||||
|
|
||||||
|
Container image:
|
||||||
|
```bash
|
||||||
|
docker build -t triple-c-sandbox ./container
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux Build Dependencies (Ubuntu/Debian)
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf libssl-dev pkg-config build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two-Process Model (Tauri IPC)
|
||||||
|
|
||||||
|
- **React frontend** (`app/src/`) renders UI in the OS webview
|
||||||
|
- **Rust backend** (`app/src-tauri/src/`) handles Docker API, credential storage, and terminal I/O
|
||||||
|
- Communication uses two patterns:
|
||||||
|
- `invoke()` — request/response for discrete operations (CRUD, start/stop containers)
|
||||||
|
- `emit()`/`listen()` — event streaming for continuous data (terminal I/O)
|
||||||
|
|
||||||
|
### Terminal I/O Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User keystroke → xterm.js onData() → invoke("terminal_input") → mpsc channel → docker exec stdin
|
||||||
|
docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → listen() → xterm.js write()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Structure (`app/src/`)
|
||||||
|
|
||||||
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
||||||
|
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
||||||
|
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
||||||
|
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||||
|
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||||
|
`moveTab` deliberately does not activate what it moves.
|
||||||
|
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
||||||
|
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
||||||
|
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
|
||||||
|
default handler types into any text field the drag is released over.
|
||||||
|
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
|
||||||
|
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
||||||
|
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
||||||
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||||
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||||
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
|
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
|
||||||
|
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
|
||||||
|
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
|
||||||
|
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
|
||||||
|
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
|
||||||
|
style nonce and CodeMirror's injected styles would stop applying. A missing or broken
|
||||||
|
`viewer.html` Vite entry is not caught by Tauri at build time — both Vite dev and Tauri's asset
|
||||||
|
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
|
||||||
|
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
|
||||||
|
in `file_viewer/mod.rs` is the only thing pinning this.
|
||||||
|
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||||
|
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||||
|
and the editors reused by Project Home
|
||||||
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
|
modals — see "UI conventions" below.
|
||||||
|
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
|
||||||
|
design — do not move it back into the webview.** The tab browses, views (text and image),
|
||||||
|
renames and creates folders inside the container (`list_container_files`,
|
||||||
|
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
|
||||||
|
copies single files in and out (`upload_files_to_container`, `download_container_file`).
|
||||||
|
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
|
||||||
|
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
|
||||||
|
entirety of its influence — it cannot name a host path as an *input*. The claim stops
|
||||||
|
there and should not be widened: host paths still travel outward in error text, canonical
|
||||||
|
ones included. What is closed is the direction that produced the criticals.
|
||||||
|
That shape is not decoration. Four successive audits found that host filesystem paths
|
||||||
|
crossing IPC were where the criticals lived — a caller-named host destination for
|
||||||
|
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
|
||||||
|
upload reservation that succeeded against a directory and failed forever on any filesystem
|
||||||
|
without hard links. The feature was removed rather than fixed a fifth time, and it came
|
||||||
|
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
|
||||||
|
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
|
||||||
|
would undo the whole point while looking like a simplification.
|
||||||
|
None of the reservation machinery came back with it. There is no destination reservation,
|
||||||
|
no placeholder rollback and no collision marker — the OS save dialog already asks about
|
||||||
|
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
|
||||||
|
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
|
||||||
|
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
|
||||||
|
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
|
||||||
|
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
|
||||||
|
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
||||||
|
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
||||||
|
at all; keep both halves and keep `PaneVisibility`). Backup is
|
||||||
|
`file_commands::download_container_backup`.
|
||||||
|
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||||
|
after canonicalisation.** That includes the general hidden-component rule, which
|
||||||
|
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||||
|
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||||
|
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||||
|
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||||
|
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
|
||||||
|
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
|
||||||
|
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
|
||||||
|
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
|
||||||
|
narrow the rule, because the terminal drop and `download_container_backup` still take
|
||||||
|
their host path over IPC and this predicate is their only boundary.
|
||||||
|
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||||
|
host staging directory were held back for separate hardening and live on
|
||||||
|
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||||
|
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||||
|
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||||
|
directory is a host-temp disk leak with a gesture attached unless its exit-clear and
|
||||||
|
startup-reap come back with it.
|
||||||
|
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth.
|
||||||
|
There is deliberately **no Disk panel** here. The disk survey and its reclaim / destroy /
|
||||||
|
compaction surface were held back for separate hardening and live on `hold/disk-and-dragout`;
|
||||||
|
one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it
|
||||||
|
returns whole, `generate_handler!` entries and typed confirmations included. The *prevention*
|
||||||
|
half stayed and is not disk-panel code: the pre-commit scrub in `docker/container.rs`, capped
|
||||||
|
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
|
||||||
|
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
|
||||||
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||||
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
|
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||||
|
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
|
||||||
|
|
||||||
|
### UI conventions
|
||||||
|
|
||||||
|
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
|
||||||
|
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
|
||||||
|
previously had ~12 hand-rolled modals; they were consolidated deliberately.
|
||||||
|
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
|
||||||
|
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
|
||||||
|
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||||
|
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
||||||
|
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
||||||
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
||||||
|
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
|
||||||
|
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
|
||||||
|
why tab-moving takes Shift.
|
||||||
|
|
||||||
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
|
- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`.
|
||||||
|
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
|
||||||
|
API via `socat`. Opt-in per project.
|
||||||
|
- **`browser_view/`** — Watch and take over the browser Claude drives with Playwright inside the
|
||||||
|
container. Runs Playwright's own dashboard (`browser.bind()` + `playwright-cli show`) in the
|
||||||
|
container and fronts it with a **token-gated** loopback proxy. Deliberately does **not** reuse
|
||||||
|
the auth bridge's `PortForward`, which binds an unauthenticated port — fine for a throwaway
|
||||||
|
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
||||||
|
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
||||||
|
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
||||||
|
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
|
||||||
|
can be watched on another monitor or pinned on top while the main window is used for work.
|
||||||
|
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
|
||||||
|
not give it one; the app CSP does not apply, because it is a top-level document rather than a
|
||||||
|
frame, and the token gate is what protects the port in both cases; and the window is owned by
|
||||||
|
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
|
||||||
|
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||||
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||||
|
the browser.
|
||||||
|
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||||
|
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||||
|
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
|
||||||
|
container-side browser closes the loop with no host round trip and no auth bridge) and dev
|
||||||
|
servers on container loopback. **Verified: a second client cannot join a bound browser** —
|
||||||
|
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||||
|
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||||
|
is the only process that can drive, which is why the helper is resident and why live resize
|
||||||
|
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||||
|
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||||
|
no second listener — and a re-open with a helper already up *navigates* rather than
|
||||||
|
relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
|
||||||
|
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
|
||||||
|
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||||
|
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||||
|
continuously and each one costs a container exec.
|
||||||
|
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||||
|
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||||
|
container stopped, process exited.
|
||||||
|
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
|
||||||
|
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
|
||||||
|
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does
|
||||||
|
not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the
|
||||||
|
wrapper alone reports a current build as "predates `browser.bind()`".
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that binds,
|
||||||
|
but never `@playwright/cli`, which is the viewer. Never offer it as a setup route — only as
|
||||||
|
what binds sessions automatically once Playwright is present.
|
||||||
|
- **`install.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is
|
||||||
|
*not* a bind mount — project directories are mounted at `/workspace/{mount_name}` — so this
|
||||||
|
touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned),
|
||||||
|
and is on the module resolution path for scripts in the project. Browsers go to
|
||||||
|
`~/.cache/ms-playwright` as `claude`, i.e. the home volume.
|
||||||
|
- **Current base images ship Chromium's shared libraries; older ones do not** — and a project
|
||||||
|
keeps the base image it was first built from until it is migrated, so "older" is the normal
|
||||||
|
case. Without them `playwright install chromium` downloads a browser that cannot launch, which
|
||||||
|
is why installing Chrome via apt looks like a fix. `install.rs` asks
|
||||||
|
`install-deps --dry-run` first and skips the apt step when the answer is "all present",
|
||||||
|
*saying so* in the progress stream. Do not decide this by probing for library names: the
|
||||||
|
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
|
||||||
|
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
|
||||||
|
everything is installed and when Playwright has no list for the platform — match on its
|
||||||
|
output, not its exit code. Either way the action ends by *actually launching* the browser to
|
||||||
|
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
|
||||||
|
offered.
|
||||||
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
|
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||||
|
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||||
|
- `image.rs` — Image build/pull with progress streaming
|
||||||
|
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||||
|
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||||
|
Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume
|
||||||
|
it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach
|
||||||
|
it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`).
|
||||||
|
`GatewayBinding` derives the bind address and the advertised `base_url` together so they
|
||||||
|
cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls —
|
||||||
|
in front of a container config holding a billed provider key. It also **always** sets a
|
||||||
|
LiteLLM `master_key`, since LiteLLM without one accepts any key.
|
||||||
|
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
|
||||||
|
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
|
||||||
|
crash-recovery state machine. See "Base-image migration" below.
|
||||||
|
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||||
|
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||||
|
users have migrated.
|
||||||
|
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||||
|
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||||
|
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||||
|
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
|
||||||
|
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LlamaCppConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
|
||||||
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
|
### Container (`container/`)
|
||||||
|
|
||||||
|
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||||
|
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||||
|
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||||
|
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||||
|
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||||
|
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||||
|
dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do
|
||||||
|
not replace it with a hand-written apt list without pinning the Playwright version you derived
|
||||||
|
it from — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
|
||||||
|
fonts) and nothing more, because `libgbm1` → `mesa-libgallium` → `libllvm20` is ~213 MiB that
|
||||||
|
no trimming removes.
|
||||||
|
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
|
||||||
|
platform Playwright's table does not cover, `install-deps` prints a warning and returns having
|
||||||
|
installed nothing **with exit status 0**. Without the assertion that ships a broken image
|
||||||
|
behind a clean build log.
|
||||||
|
- Baking the libraries but not the browsers is the whole point of the split. Browsers live in
|
||||||
|
`~/.cache/ms-playwright` (home volume) and already survive recreation *and* migration; a
|
||||||
|
runtime `apt-get install` of the libraries lands in the writable layer, is re-paid after every
|
||||||
|
Reset, and is **lost on base-image migration**, which replays apt from a manifest. The runtime
|
||||||
|
approach converges on the worst state: a 400 MB browser present with its libraries gone.
|
||||||
|
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
|
||||||
|
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
||||||
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||||
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
|
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||||
|
`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory
|
||||||
|
is masked permanently and can never be updated again. A change you make under `/home/claude` in
|
||||||
|
the `Dockerfile` or in `entrypoint.sh`'s "copy this into the home dir" style reaches **new
|
||||||
|
projects only** — existing ones will never see it, with or without a base-image migration.
|
||||||
|
|
||||||
|
So: **anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or must be seeded
|
||||||
|
by `entrypoint.sh` at runtime** (i.e. written on every start, from a source outside the home
|
||||||
|
volume, the way `CLAUDE_INSTRUCTIONS` → `~/.claude/CLAUDE.md` and the Mission Control skill copy
|
||||||
|
already are). Putting it in the image's `/home/claude` and expecting an image update to deliver it
|
||||||
|
is the mistake.
|
||||||
|
|
||||||
|
The flip side is the useful half of the same fact: Claude Code itself (`~/.local/bin`), cargo, uv,
|
||||||
|
ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks and SSH keys all
|
||||||
|
re-attach for free when a container is recreated from a *different* image — which is what makes
|
||||||
|
base-image migration cheap.
|
||||||
|
|
||||||
|
### Corporate CA certificates (`docker/ca_certs.rs`, `entrypoint.sh`)
|
||||||
|
|
||||||
|
A global `AppSettings::ca_cert_path` with a per-project `Project::ca_cert_path` override, accepting
|
||||||
|
a single certificate file **or** a directory. Follows the SSH/AWS host-mount pattern: read-only
|
||||||
|
bind mount at `/tmp/.host-ca`, applied by the entrypoint on every start, so it survives recreation,
|
||||||
|
migration and Reset. Four things here are not obvious:
|
||||||
|
|
||||||
|
- **`update-ca-certificates` globs `*.crt`, case-sensitively.** A `.pem` that is merely copied into
|
||||||
|
`/usr/local/share/ca-certificates/` is ignored in total silence. Certificates are *renamed* —
|
||||||
|
`container_cert_name()` in Rust, mirrored in a few lines of shell in `entrypoint.sh` (the Rust
|
||||||
|
side carries the unit tests). A single-file mount lands at `/tmp/.host-ca/<name>.crt` so the
|
||||||
|
entrypoint only ever sees a directory and the file keeps a recognisable name.
|
||||||
|
- **The system store is not enough.** Only curl/git/apt read it. Node — and therefore Claude Code
|
||||||
|
itself — needs `NODE_EXTRA_CA_CERTS`; Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`;
|
||||||
|
Chrome/Chromium read neither and want their own NSS database at `~/.pki/nssdb`, seeded with
|
||||||
|
`certutil` (`libnss3-tools`, added to the image for this). The NSS step warns and continues if
|
||||||
|
`certutil` is missing rather than failing the start.
|
||||||
|
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||||
|
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||||
|
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. The bundle path
|
||||||
|
is deterministic (`/etc/ssl/certs/ca-certificates.crt`), so Rust can set them up front. They are
|
||||||
|
emitted **empty** when no CA is configured, for the `MANAGED_AUTH_KEYS` reason: `docker commit`
|
||||||
|
bakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl, `openssl
|
||||||
|
s_client` and Python's `ssl` behave exactly as with the vars unset.
|
||||||
|
- **`triple-c.ca-fingerprint` covers the certificate *bytes*, not just the path.** Replacing a
|
||||||
|
rotated CA at the same location must recreate the container; the copy inside is made once, at
|
||||||
|
start, so nothing else would notice. The entrypoint is stamped/idempotent on restart, and
|
||||||
|
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
||||||
|
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
|
### VPN support (`vpn_support_enabled`, `docker/container.rs`)
|
||||||
|
|
||||||
|
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
|
||||||
|
`vpn_host_config()` is the single definition of what that means, and it is unit-tested because a
|
||||||
|
container is created once by a very long function where a dropped capability is invisible.
|
||||||
|
|
||||||
|
- **All three pieces or none.** `CAP_NET_ADMIN` (Docker's default set has `net_raw` but *not*
|
||||||
|
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
|
||||||
|
entirely from a default container — nothing to open even with the capability), and
|
||||||
|
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
|
||||||
|
container, since `/proc/sys` is read-only, so handshake packets die to reverse-path filtering).
|
||||||
|
Any two without the third still presents as a connection that hangs to a timeout, which is why
|
||||||
|
the tests assert the whole set.
|
||||||
|
- **The device is passed through from the host, never `mknod`-ed inside.** The kernel's `tun`
|
||||||
|
module has to back it.
|
||||||
|
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
|
||||||
|
create --device /dev/does-not-exist` succeeds and prints an id; runc resolves the device (and
|
||||||
|
validates sysctls) only when it builds the container. So the guard belongs on the start path:
|
||||||
|
`explain_container_failure()` covers both and is called from `start_container`, where it has a
|
||||||
|
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
|
||||||
|
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
|
||||||
|
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
|
||||||
|
- **`NET_ADMIN` here is not user-namespaced.** Docker does not enable userns remapping by default,
|
||||||
|
so only the *network* namespace confines it: no reach onto host interfaces, but promiscuous
|
||||||
|
mode, arbitrary addresses/routes/NAT on the shared `docker0` segment (sibling containers, the
|
||||||
|
LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and
|
||||||
|
enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the
|
||||||
|
code comments honest about this — an earlier draft claimed it "confers no authority" outside the
|
||||||
|
container, which is too strong.
|
||||||
|
- **`triple-c.vpn-support` is written unconditionally, including `false`.** The usual
|
||||||
|
`docker commit` reason: a `true` stamped once would ride the snapshot image into every future
|
||||||
|
container and make the switch impossible to turn off.
|
||||||
|
- Off is byte-identical to a container created before the feature existed, and a missing label
|
||||||
|
reads as `false`, so no existing project is churned.
|
||||||
|
- **The toggle grants capability and stops there — it routes nothing.** `vpn_host_config()` returns
|
||||||
|
a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started
|
||||||
|
or restored. Users read the name as "turn the VPN on" and report the default network not routing
|
||||||
|
through it as a bug. It isn't, and the docs say so explicitly; keep it that way.
|
||||||
|
- **The tooling is baked, not installed at runtime.** `iproute2` and `wireguard-tools` are in
|
||||||
|
`container/Dockerfile` because a runtime install lands in the writable layer and is lost on
|
||||||
|
base-image migration — leaving a project holding the capability with nothing able to exercise it,
|
||||||
|
and no error that points at why. `iptables` is included and `nftables` deliberately is not; see
|
||||||
|
the Dockerfile comment for why that way round.
|
||||||
|
- **Anything built on this fails open.** The network namespace is rebuilt on every start and no
|
||||||
|
service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover
|
||||||
|
`/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
|
||||||
|
writable layer, so on a stop/start it is simply the same container's files, and on a recreation
|
||||||
|
`docker commit` has carried it into the snapshot. Traffic silently reverts to the real address.
|
||||||
|
Any future autostart or killswitch work starts here.
|
||||||
|
- **`/run` riding the snapshot means a VPN client's key material can end up in an image.** Verified:
|
||||||
|
a fresh container off the whp snapshot already contained the `wg.priv` a previous tunnel left in
|
||||||
|
`/run`. Anything writing key material there inherits the problem — the same `docker commit`
|
||||||
|
hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks
|
||||||
|
ephemeral and is not. A VPN client that does this should delete its key on teardown.
|
||||||
|
- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
|
||||||
|
nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
|
||||||
|
any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
|
||||||
|
the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
|
||||||
|
`nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
|
||||||
|
`xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment;
|
||||||
|
the kernel-config evidence is quoted there.
|
||||||
|
- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need
|
||||||
|
`xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every
|
||||||
|
provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it
|
||||||
|
breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in
|
||||||
|
systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route`
|
||||||
|
directly avoids both, which is what the skill does.
|
||||||
|
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
|
||||||
|
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
|
||||||
|
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
|
||||||
|
image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
|
||||||
|
The removal branch matters as much as the install: `~/.claude` is a persisted volume, so a skill
|
||||||
|
left behind after the toggle goes off would keep instructing an agent to use a capability the
|
||||||
|
container no longer has. Which is also why the variable is sent as `0` rather than omitted (see
|
||||||
|
`vpn_env_var`, tested), and why it is in `RESERVED_ENV_EXACT` — a custom env var of that name
|
||||||
|
could otherwise claim the skill without the capability behind it.
|
||||||
|
- **Both halves of that live in the base image, so neither reaches an existing project.** A
|
||||||
|
recreation builds from the project's *own snapshot*, which has no `/opt/triple-c-skills` and no
|
||||||
|
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
|
||||||
|
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so the
|
||||||
|
migration pre-flight lists it as missing. Worth knowing before adding anything else behind an
|
||||||
|
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||||
|
so a project already at `true` gets no recreation at all on upgrade.
|
||||||
|
|
||||||
|
### Keeping Claude Code current
|
||||||
|
|
||||||
|
`claude update` runs in **two** places, and both are needed:
|
||||||
|
|
||||||
|
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||||
|
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||||
|
command every Claude session launches with, because containers use a stop/start model and a
|
||||||
|
long-lived one would otherwise never re-check.
|
||||||
|
|
||||||
|
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||||
|
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||||
|
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||||
|
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||||
|
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||||
|
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||||
|
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||||
|
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||||
|
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||||
|
deliberately untouched.
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
|
|
||||||
|
**Reset is the exception and it is destructive.** `rebuild_project_container` calls
|
||||||
|
`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`,
|
||||||
|
`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is
|
||||||
|
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||||
|
preserving credentials.
|
||||||
|
|
||||||
|
### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`)
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it — so without an explicit act, a project stays on the base image it
|
||||||
|
was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a
|
||||||
|
security update. Migration is the non-destructive way out; Reset is the destructive one.
|
||||||
|
|
||||||
|
- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records
|
||||||
|
the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long
|
||||||
|
comment there. Comparing it would recreate every project *from its own snapshot* on the next base
|
||||||
|
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`
|
||||||
|
label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback.
|
||||||
|
- **Rollback restores the system layer only.** The volumes are never touched at any point, so work
|
||||||
|
done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy.
|
||||||
|
- **`/var` is never copied either, and that is the one way migration is *more* destructive than
|
||||||
|
the ordinary recreate.** A recreate builds from the project's snapshot, so `/var/lib/postgresql`
|
||||||
|
rides along; a migration builds from the base and the apt replay hands back an empty cluster.
|
||||||
|
Copying a live database's files onto a different base's version of the same package is a
|
||||||
|
corruption risk, not a fix — so the answer is disclosure. `unpreserved_data()` reports
|
||||||
|
first-level directories under `/var/lib` and `/var/www` that the base does not ship *and* that
|
||||||
|
hold non-dpkg-owned files (which is what keeps `/var/lib/apt` and `/var/lib/dpkg` out of it),
|
||||||
|
and the pre-flight, the banner and the finished report all name them. Do not make this silent.
|
||||||
|
- **The rollback pin is not best-effort.** After `commit_container_snapshot` the commit is the only
|
||||||
|
copy of the old system layer, so a `docker tag` that fails — or succeeds without the reference
|
||||||
|
resolving — aborts the migration before `remove_container`. Same rule in reverse for
|
||||||
|
`rollback_migration`: the image is confirmed to exist before the container is destroyed.
|
||||||
|
- **`resume` must check the container's `triple-c.migration-state` label**, exactly as
|
||||||
|
`reconcile_migration` does. Without it a record left behind by a failed commit "resumes" into
|
||||||
|
the *old, unmigrated* container and commits it as migrated.
|
||||||
|
- **Anything that stops, removes or recreates a project's container consults
|
||||||
|
`migration_commands::is_migrating`.** The window between `remove_container` and the create that
|
||||||
|
follows looks exactly like "no container" to Start, and Reset would delete the volumes out from
|
||||||
|
under a live run.
|
||||||
|
- **`/etc` is never copied**, only reported: the snapshot lineage has
|
||||||
|
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||||
|
having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical.
|
||||||
|
- **`docker diff` is useless here** — on a snapshot-derived container it reports only changes since
|
||||||
|
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
|
||||||
|
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
|
||||||
|
differences into 2 genuinely user-authored ones.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Per-project, independently configured:
|
||||||
|
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||||
|
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||||
|
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||||
|
- **llama.cpp** — Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080`, its default port)
|
||||||
|
- **OpenAI Compatible** — Connect through a gateway implementing the **Anthropic Messages API** (LiteLLM) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
||||||
|
|
||||||
|
**Claude Code only ever speaks the Anthropic Messages API** (`POST /v1/messages?beta=true`) to
|
||||||
|
`ANTHROPIC_BASE_URL` — never OpenAI's `/v1/chat/completions`. Ollama and llama.cpp implement
|
||||||
|
`/v1/messages` natively, which is why each gets a plain base-URL backend with no translation shim.
|
||||||
|
A server that only exposes an OpenAI-shaped API does not work behind any backend.
|
||||||
|
|
||||||
|
For every backend pointing at a custom endpoint (`Backend::uses_custom_endpoint`), all four
|
||||||
|
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` vars are pinned to the backend's configured
|
||||||
|
model id, with an optional per-backend Haiku override. Without this, Claude Code's background
|
||||||
|
calls resolve `haiku` to an Anthropic model id the local server does not have and fail silently.
|
||||||
|
Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||||
|
`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and must not be used.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`). No separate tailwind config file.
|
||||||
|
- All colors use CSS custom properties in `index.css` `:root` (e.g., `--bg-primary`, `--text-secondary`, `--accent`)
|
||||||
|
- `color-scheme: dark` is set on `:root` for native dark-mode controls
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- 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![...])`
|
||||||
|
- **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
|
||||||
|
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
||||||
|
and compare it there, or the change will silently not take effect until some unrelated setting
|
||||||
|
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
||||||
|
(`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the
|
||||||
|
reasoning is in the comment beside the check.)
|
||||||
|
- **Always write a `triple-c.*` label explicitly, even when the value is empty.** Docker merges an
|
||||||
|
image's labels into a container's at creation, and `docker commit` copies container labels onto
|
||||||
|
the snapshot image — so a label stamped once rides that snapshot into *every* future container
|
||||||
|
forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not
|
||||||
|
been written by any code since the MCP feature was removed, yet a snapshot image was found still
|
||||||
|
carrying a non-empty one, which made its one-shot recreation shim recreate that project on every
|
||||||
|
single start. Writing the key explicitly overrides the inherited value — the same defence
|
||||||
|
`MANAGED_AUTH_KEYS` applies to env vars.
|
||||||
|
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
||||||
|
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||||
|
`models/project.rs` for anything that should default to true.
|
||||||
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
|
||||||
|
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||||
|
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||||
|
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||||
|
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||||
|
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||||
|
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||||
|
for `container/**`.
|
||||||
|
|
||||||
|
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||||
|
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||||
|
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||||
|
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||||
|
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||||
|
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||||
|
catches the real incident (`9b2f4fe`) when replayed.
|
||||||
|
|
||||||
|
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||||
|
for it.
|
||||||
|
|
||||||
|
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||||
|
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||||
|
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||||
|
independent reviews, because every one of them read the code under change and this sat in a test
|
||||||
|
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||||
|
|
||||||
|
## Settings export/import
|
||||||
|
|
||||||
|
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||||
|
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||||
|
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||||
|
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||||
|
deliberately out of scope — this is not a project backup.
|
||||||
|
|
||||||
|
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||||
|
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||||
|
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||||
|
have carried it along as if it were as inert as a port number, and importing it would have
|
||||||
|
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||||
|
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||||
|
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||||
|
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||||
|
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||||
|
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||||
|
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||||
|
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||||
|
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||||
|
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||||
|
like config but is actually a live credential would have the same problem.
|
||||||
|
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||||
|
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||||
|
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||||
|
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||||
|
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||||
|
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||||
|
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||||
|
every export already gives it.
|
||||||
|
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||||
|
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||||
|
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||||
|
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||||
|
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||||
|
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||||
|
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||||
|
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||||
|
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||||
|
otherwise shared directory that changes between the two calls.
|
||||||
|
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||||
|
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||||
|
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||||
|
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||||
|
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||||
|
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||||
|
render directly.
|
||||||
|
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||||
|
An import is "restore this environment," so the settings half is a full replace, not a
|
||||||
|
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||||
|
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||||
|
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||||
|
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||||
|
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||||
|
window where a gateway recreation happens against the destination's old keys.
|
||||||
|
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||||
|
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||||
|
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||||
|
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||||
|
alone, a running container would keep serving the old key material indefinitely after an import
|
||||||
|
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||||
|
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||||
|
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||||
|
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||||
|
exactly when it should and no more.
|
||||||
|
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||||
|
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||||
|
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||||
|
third must not read as unqualified success just because the settings half of the import (which
|
||||||
|
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||||
|
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||||
|
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||||
|
- **The imported settings are validated *before* any secret is written, not just before the
|
||||||
|
settings replace.** `apply_settings_import` calls
|
||||||
|
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||||
|
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||||
|
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||||
|
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||||
|
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||||
|
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||||
|
hint of.
|
||||||
|
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||||
|
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||||
|
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||||
|
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||||
|
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||||
|
here can hold a live credential.
|
||||||
|
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||||
|
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||||
|
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||||
|
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||||
|
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||||
|
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||||
|
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||||
|
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||||
|
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||||
|
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||||
|
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||||
|
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||||
|
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||||
|
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||||
|
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||||
|
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||||
|
token when none is already set, so a planted token would otherwise activate silently the next
|
||||||
|
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||||
|
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||||
|
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||||
|
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||||
|
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||||
|
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||||
|
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||||
|
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||||
|
it differs from the destination's current value, since re-importing the same risky configuration
|
||||||
|
is still worth surfacing every time a user confirms an import.
|
||||||
|
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||||
|
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||||
|
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||||
|
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||||
|
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||||
|
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||||
|
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||||
|
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||||
|
as a second layer against the same failure mode.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||||
|
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||||
|
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||||
|
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
|
||||||
|
Windows are untouched.
|
||||||
|
|
||||||
|
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
|
||||||
|
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
|
||||||
|
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
|
||||||
|
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
|
||||||
|
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
|
||||||
|
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
|
||||||
|
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
|
||||||
|
metadata and update information**, without which an AppImage manager can adopt the app but never
|
||||||
|
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
|
||||||
|
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
|
||||||
|
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
|
||||||
|
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
|
||||||
|
|
||||||
|
**There is deliberately no Arch package.** A
|
||||||
|
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||||
|
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||||
|
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||||
|
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||||
|
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||||
|
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||||
|
the AppImage is the Arch story.
|
||||||
|
|
||||||
|
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||||
|
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||||
|
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||||
|
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||||
|
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||||
|
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||||
|
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||||
|
|
||||||
|
### Windows code signing
|
||||||
|
|
||||||
|
Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary,
|
||||||
|
the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures"
|
||||||
|
fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship
|
||||||
|
quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not
|
||||||
|
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
|
||||||
|
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
|
||||||
|
secret available there is available to whoever can push a branch. To exercise signing before a
|
||||||
|
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
|
||||||
|
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
|
||||||
|
|
||||||
|
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
|
||||||
|
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
|
||||||
|
**each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri
|
||||||
|
config file with `bundle.windows.signCommand` that the build passes as
|
||||||
|
`cargo tauri build --config`. Nothing is installed on the build VM. To bump
|
||||||
|
a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download.
|
||||||
|
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
|
||||||
|
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
|
||||||
|
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
|
||||||
|
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
|
||||||
|
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
|
||||||
|
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
|
||||||
|
plugins, and signing those would more than double the metered count for no user-visible
|
||||||
|
benefit. If the app ever ships resource DLLs or sidecars, extend the
|
||||||
|
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
|
||||||
|
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
|
||||||
|
`signtool /debug` included), and the job prints it on failure.
|
||||||
|
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the
|
||||||
|
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
|
||||||
|
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
|
||||||
|
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
|
||||||
|
installer, which cannot be unpacked that way, it requires the signing log to show the app
|
||||||
|
binary and the uninstaller were signed.
|
||||||
|
|
||||||
|
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
|
||||||
|
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
|
||||||
|
two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri
|
||||||
|
app"), never by the preview workflow, never echoed, and never on a command line. The repo is
|
||||||
|
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
|
||||||
|
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
|
||||||
|
repo can reach the secrets through a workflow file, so repo write access is the boundary.
|
||||||
|
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
|
||||||
|
release only ever comes from a merged, visible change. The
|
||||||
|
Azure side should hold the rest: an app registration with only the signer role on this one
|
||||||
|
certificate profile, and a client secret with an expiry. Four things are load-bearing:
|
||||||
|
|
||||||
|
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
|
||||||
|
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as
|
||||||
|
SYSTEM, where that waits forever for a browser.
|
||||||
|
- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read
|
||||||
|
that variable — it only sets it, for tauri-build — so a config put there is dropped without an
|
||||||
|
error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied;
|
||||||
|
"Verify signatures" is what exposed it, and it is what would catch a regression.
|
||||||
|
- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is
|
||||||
|
written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64
|
||||||
|
redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64
|
||||||
|
signtool, so they would disagree about where the file is. The workspace is under
|
||||||
|
`systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores
|
||||||
|
the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it
|
||||||
|
signs and the verify step requires a logged signature under that temp directory.
|
||||||
|
- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the
|
||||||
|
`winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's
|
||||||
|
`signtool` (10.0.26100) but no .NET — hence the job-local runtime.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# Triple-C Design & Product Review
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Version reviewed:** 0.3.0 · **Reviewer:** Fable 5
|
||||||
|
|
||||||
|
Scope: `app/src/` (App, layout, projects, settings, terminal, ui, store, index.css),
|
||||||
|
README/CLAUDE.md/TODO.md, the four repo screenshots, and `triple-c-app-logov2.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary verdict
|
||||||
|
|
||||||
|
The bones are good. The floating-panel layout reads clean, the GitHub-dark palette is
|
||||||
|
inoffensive, and terminal-as-centerpiece is correct for this product.
|
||||||
|
|
||||||
|
The two real problems are structural, and they are the same problem seen from two sides:
|
||||||
|
**the project — the app's actual unit of work — has no room to live.** Everything about a
|
||||||
|
project (backend auth, mounts, git identity, env vars, ports, Claude settings, file
|
||||||
|
manager) is stuffed into a ~280px sidebar card (`ProjectCard.tsx`, 1,257 lines) that
|
||||||
|
sprays out seven modals to compensate.
|
||||||
|
|
||||||
|
`screenshot_for_fix/project_config_run_off.png` is not a bug to patch. It is the
|
||||||
|
architecture reporting that the config does not fit where it lives. Fixing that one thing
|
||||||
|
also solves the modal pile, the density problems, *and* creates the surface where newer
|
||||||
|
Claude Code concepts belong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Visual & interaction design
|
||||||
|
|
||||||
|
### A1. Tokens: coherent but thin, with one real contrast failure
|
||||||
|
|
||||||
|
`index.css` is GitHub Primer dark, verbatim (`#0d1117 / #161b22 / #21262d / #30363d /
|
||||||
|
#8b949e / #58a6ff`). Defensible — familiar, calm, terminal-adjacent — but the token layer
|
||||||
|
stops at 11 variables. Roles the code is already faking ad hoc:
|
||||||
|
|
||||||
|
- **No elevation/overlay token.** Modals reuse `--bg-secondary`, so a modal over the
|
||||||
|
sidebar is the same color as the sidebar. Add `--bg-overlay: #1c2128` and
|
||||||
|
`--shadow-overlay`.
|
||||||
|
- **No muted-accent tokens.** The code hand-rolls `bg-yellow-500/20 text-yellow-400`,
|
||||||
|
`bg-blue-500/20 text-blue-400`, `--warning/15`, `--error/10`. Add `--accent-muted`,
|
||||||
|
`--warning-muted`, `--error-muted`, `--success-muted`. Those raw Tailwind palette colors
|
||||||
|
are the only two places the token system leaks.
|
||||||
|
- **Radius drift:** `rounded` (4px), `rounded-lg` (8px), plus hardcoded 3px/6px in help
|
||||||
|
styles. Pick two: 6px controls, 8px panels.
|
||||||
|
|
||||||
|
**Contrast bug (concrete):** white text on `--accent #58a6ff` is ~**2.5:1** — fails WCAG
|
||||||
|
AA. That is the primary button ("Add Project"), the "Update" pill, and more. Primer solves
|
||||||
|
this with two accents: keep `#58a6ff` as the *foreground/link* accent and add
|
||||||
|
`--accent-emphasis: #1f6feb` for filled buttons (white on `#1f6feb` ≈ 4.7:1).
|
||||||
|
|
||||||
|
Same story for `bg-[var(--success)] text-white` ON toggles — `#3fb950` + white ≈ **2.1:1**,
|
||||||
|
the worst offender in the app.
|
||||||
|
|
||||||
|
What passes: `--text-secondary #8b949e` on `#161b22` ≈ 5.8:1, fine even at 12px.
|
||||||
|
`--warning #d29922` ≈ 7:1. But `disabled:opacity-50` on secondary text drops to ~2.4:1 —
|
||||||
|
and since the entire config form is disabled while the container runs, **the most common
|
||||||
|
state of the form is illegible.** Use a dedicated `--text-disabled: #6e7681` instead of
|
||||||
|
opacity.
|
||||||
|
|
||||||
|
### A2. Type and density: everything is 12px
|
||||||
|
|
||||||
|
Roughly 90% of the UI is `text-xs`. Hierarchy is carried almost entirely by weight plus a
|
||||||
|
single `text-lg` modal title. Forms feel cramped rather than dense — density is
|
||||||
|
information per pixel, not small type.
|
||||||
|
|
||||||
|
Proposed scale with roles: **11px** uppercase section labels (already used, keep) ·
|
||||||
|
**12px** secondary/meta · **13px** default UI/body/form values · **14px** panel headers ·
|
||||||
|
**16px** view titles.
|
||||||
|
|
||||||
|
Path strings in mono are a nice identity touch — extend mono to all machine values (model
|
||||||
|
IDs, ports, digests), which the Bedrock/Ollama forms currently render in the UI face.
|
||||||
|
|
||||||
|
The outer chrome spends generously while content starves: `App.tsx` wraps everything in
|
||||||
|
`p-6 gap-4`, then the config form gets ~180px-wide inputs for AWS secret keys. Keep the
|
||||||
|
floating-island look; `p-3 gap-3` buys content ~24px horizontally and the terminal two
|
||||||
|
more rows.
|
||||||
|
|
||||||
|
### A3. The project card is three components wearing one div
|
||||||
|
|
||||||
|
`ProjectCard` is simultaneously a list row, a command strip, and the entire settings form.
|
||||||
|
|
||||||
|
- **Selection and disclosure are conflated.** Clicking a row both selects it and expands an
|
||||||
|
accordion in place, shoving the other projects down. The 06-28 screenshot shows 18
|
||||||
|
projects — this jank is daily.
|
||||||
|
- **Actions are unstyled text links.** `ActionButton` renders `text-xs px-2 py-0.5` colored
|
||||||
|
text with no border or background, so Start/Stop/Terminal/Shell/Files/Backup/Config/Remove
|
||||||
|
read as a wrapping line of links. Worse, **Remove (destructive, red) wraps directly next
|
||||||
|
to Config** with a ~20px hit target.
|
||||||
|
- **Double-click-to-rename** is undiscoverable and keyboard/touch-inaccessible.
|
||||||
|
- **27 hover-only `<Tooltip>` markers in ProjectCard alone.** When a form needs 27 tooltips,
|
||||||
|
the form is the problem.
|
||||||
|
|
||||||
|
### A4. Modals: eight is a pattern smell, and none are real dialogs
|
||||||
|
|
||||||
|
Hanging off ProjectCard: EnvVars, PortMappings, ClaudeInstructions, ClaudeCodeSettings,
|
||||||
|
ContainerProgress, FileManager, ConfirmRemove — plus AddProject, three reused from
|
||||||
|
SettingsPanel, and Update/ImageUpdate/Help from TopBar.
|
||||||
|
|
||||||
|
Each reimplements the overlay div, Escape handler, and click-outside logic by hand. **None
|
||||||
|
has `role="dialog"`, `aria-modal`, a focus trap, or focus restore** — zero hits for
|
||||||
|
`role=`, `aria-modal`, or `tabIndex` across `components/`.
|
||||||
|
|
||||||
|
The pattern is wrong not because modals are bad, but because these are not modal *tasks*.
|
||||||
|
Env vars, ports, instructions, and Claude settings are all "edit part of the project
|
||||||
|
config" — a detail view's job.
|
||||||
|
|
||||||
|
- Legitimately modal: **ConfirmRemove**, **AddProject**.
|
||||||
|
- **FileManager** wants to be a main-area tab, not a 42rem popup.
|
||||||
|
- **ContainerProgressModal actively hurts:** starting a container blocks the entire app
|
||||||
|
behind an overlay for an operation designed to be routine. Replace with inline row state
|
||||||
|
plus an error toast.
|
||||||
|
- Whatever survives should be one shared `<Modal>` primitive with focus trap + ARIA.
|
||||||
|
|
||||||
|
### A5. Keyboard and focus: currently unsupported
|
||||||
|
|
||||||
|
For a tool whose centerpiece is a keyboard-driven terminal, the chrome is mouse-only.
|
||||||
|
|
||||||
|
- Inputs use `focus:outline-none` with only a low-contrast border swap; **buttons have no
|
||||||
|
focus style at all** — tabbing through the sidebar is invisible.
|
||||||
|
- One-line fix: add `--focus-ring: #58a6ff` and
|
||||||
|
`:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }`
|
||||||
|
- No shortcuts for constant actions: `Ctrl+T` new terminal, `Ctrl+Tab`/`Ctrl+1..9` switch,
|
||||||
|
`Ctrl+W` close, `Ctrl+P` project switcher. The only shortcut in the app is the STT mic.
|
||||||
|
- Hit targets below 24px: tab close "×" (~14px), Tooltip "?" (14px), Browse "...". The
|
||||||
|
status bar is `h-6` yet hosts two interactive controls.
|
||||||
|
|
||||||
|
### A6. Status communication
|
||||||
|
|
||||||
|
Three disconnected dot systems (TopBar Docker/Image, per-project status, StatusBar counts),
|
||||||
|
all 8px and color-only.
|
||||||
|
|
||||||
|
- **Stopped (gray) and error (red) differ only by hue**, and Docker-unavailable renders the
|
||||||
|
same gray as Docker-still-being-checked (`dockerAvailable === null` and `false` both fall
|
||||||
|
through). An outage should be loud; unknown should pulse.
|
||||||
|
- Color-only encoding fails colorblind users. Add shape or text — `● Running`, `○ Stopped`,
|
||||||
|
`⚠ Error`. The words are already in the model.
|
||||||
|
- Raw `String(e)` errors dumped into a 12px card line; bollard errors are long. Errors need
|
||||||
|
a home: toast plus expandable detail.
|
||||||
|
- The TopBar tab strip is visually disconnected from the terminal it controls. Move tabs
|
||||||
|
onto the terminal panel's top edge so the active tab connects to its content.
|
||||||
|
|
||||||
|
### A7. Empty and first-run states
|
||||||
|
|
||||||
|
`WelcomeScreen` is three lines of gray text with no affordance — "Add a project from the
|
||||||
|
sidebar" *describes* a button instead of *being* one. This is also where brand could exist:
|
||||||
|
the orange sun-gear logo appears nowhere in the UI and shares no DNA with the blue-on-
|
||||||
|
graphite chrome.
|
||||||
|
|
||||||
|
Make it an onboarding checklist reusing state already tracked:
|
||||||
|
✓ Docker detected → ✓ Image pulled → **[ Add your first project ]** → open terminal.
|
||||||
|
The same pattern fixes the "image missing" case, today just a gray dot in the corner.
|
||||||
|
|
||||||
|
### A8. Dark-only: keep it
|
||||||
|
|
||||||
|
Right call. Terminal-first developer tool, xterm content is dark, audience expects it. The
|
||||||
|
tokens make a light theme cheap later. Don't spend on it now — but keep discipline that no
|
||||||
|
color bypasses the token layer.
|
||||||
|
|
||||||
|
### A9. Iconography
|
||||||
|
|
||||||
|
Mixed: hand-inlined Feather-style SVGs in the sidebar rail, text glyphs elsewhere ("×",
|
||||||
|
"?", "...", "+", "✓", "✕"). Adopt `lucide-react` — same stroke style already being
|
||||||
|
imitated, tree-shakeable — and replace the text glyphs. It also supplies the per-concept
|
||||||
|
icons Part B needs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — Information architecture & product concepts
|
||||||
|
|
||||||
|
### B1. The diagnosis
|
||||||
|
|
||||||
|
Current IA: `Projects | MCP | Settings` in a sidebar, terminal in main, project detail
|
||||||
|
crammed into the list.
|
||||||
|
|
||||||
|
Deleting the MCP tab was correct — but **the lesson matters more than the freed slot.
|
||||||
|
MCP died as a Triple-C feature because Claude Code absorbed it.** Hooks, skills, agents,
|
||||||
|
plugins, output styles, and statusline are all the same species: files under `.claude/`
|
||||||
|
that Claude Code manages natively with its own TUIs (`/agents`, `/hooks`, `/plugins`). If
|
||||||
|
Triple-C builds form editors for them, it loses the same race again and becomes exactly
|
||||||
|
what it should fear — a settings-file editor with a GUI skin.
|
||||||
|
|
||||||
|
What Claude Code *cannot* do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it.** The config volume, the workspace mounts, the lifecycle, the
|
||||||
|
scheduler already shipping in every image, and the fleet view across many projects.
|
||||||
|
|
||||||
|
> **Principle: Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Sessions, checkpoints, background tasks, scheduled tasks, capability inventory → surface
|
||||||
|
them, read from the volume, launch into the terminal. Hook/skill/agent *editing* →
|
||||||
|
deep-link into the terminal, don't rebuild.
|
||||||
|
|
||||||
|
### B2. Proposed IA: three nouns
|
||||||
|
|
||||||
|
**Project** (a sandboxed workspace) · **Session** (a resumable conversation) ·
|
||||||
|
**Library** (reusable capabilities pushed into projects). Everything is one of these, or
|
||||||
|
Settings.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar: ⌂ api-server │ ▣ api-server ✕ │ ▣ api (bash) ✕ │ ● ● ? │
|
||||||
|
├─────────────┬──────────────────────────────────────────────────────┤
|
||||||
|
│ ◤ Projects │ MAIN AREA — a tab strip of two tab kinds: │
|
||||||
|
│ ● api-serv │ ⌂ project-home tabs ▣ terminal tabs │
|
||||||
|
│ ○ blog │ │
|
||||||
|
│ ● data-pipe│ ⌂ api-server ● Running · 2h 14m │
|
||||||
|
│ … │ ┌─────────┬──────────┬────────────┬────────┐ │
|
||||||
|
│ ◧ Library │ │Overview │ Sessions │ Automation │ Config │ │
|
||||||
|
│ ⚙ Settings │ └─────────┴──────────┴────────────┴────────┘ │
|
||||||
|
├─────────────┴──────────────────────────────────────────────────────┤
|
||||||
|
│ StatusBar: 18 projects · 8 running · 4 terminals 🎤 ↓Jump │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sidebar** becomes a pure list plus nav rail. Rows carry name, path, status dot, and on
|
||||||
|
hover a play/stop and terminal button. Clicking opens (or focuses) that project's
|
||||||
|
**Project Home** tab. The freed MCP slot becomes **Library**.
|
||||||
|
- **Main area** hosts two tab kinds: terminals (as today) and project-home tabs, like VS
|
||||||
|
Code's Settings tab. The terminal stays the centerpiece; Project Home is one keystroke
|
||||||
|
away rather than a layer on top.
|
||||||
|
- **All seven config modals dissolve** into the Config tab, full-width, grouped:
|
||||||
|
*Workspace* (folders/mounts), *Model* (backend + auth), *Access* (git/SSH/env/ports),
|
||||||
|
*Runtime* (docker access, sandbox, permission mode, Mission Control). Room for visible
|
||||||
|
helper text kills most of the 27 tooltips. Save-on-blur stays but gains a visible
|
||||||
|
"Saved ✓ / Failed" indicator — today failures go only to `console.error`, which is
|
||||||
|
silent data loss.
|
||||||
|
|
||||||
|
#### Project Home — Overview tab
|
||||||
|
|
||||||
|
```
|
||||||
|
api-server ● Running · started 2h ago
|
||||||
|
[ Stop ] [ Open Claude Terminal ] [ Shell ] [ Files ] [⋯ menu]
|
||||||
|
|
||||||
|
Permission mode ( Plan ) ( Default ) ( Accept Edits ) (▮ Bypass ▮)
|
||||||
|
Sandbox ON — bubblewrap isolation Backend Anthropic
|
||||||
|
|
||||||
|
CAPABILITIES (read from container volume)
|
||||||
|
◆ Skills 7 ◆ Agents 3 ◆ Hooks 2 ◆ Plugins 1 ◆ Commands 5
|
||||||
|
└ click any → drawer listing names/descriptions,
|
||||||
|
[Manage in terminal] → opens claude with /agents etc.
|
||||||
|
|
||||||
|
RECENT SESSIONS SCHEDULED TASKS
|
||||||
|
"Refactor OAuth flow" 2h ago [Resume] nightly-review 0 3 * * *
|
||||||
|
"Fix flaky CI test" 1d ago [Resume] [2 notifications]
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. The four concepts worth building
|
||||||
|
|
||||||
|
**1. Sessions & Resume — the flagship.** The stop/start container model creates a problem
|
||||||
|
plain Claude Code doesn't have: stop a container, come back Tuesday, and "which
|
||||||
|
conversation was I in?" is buried in the volume. Read session metadata via `docker exec`
|
||||||
|
(the exec and tar plumbing already exists), list sessions with summary and age, and make
|
||||||
|
**[Resume]** open a terminal running `claude --resume <id>`. Closing a terminal tab today
|
||||||
|
silently abandons a session; it should say "Session saved — resume from Project Home."
|
||||||
|
This turns the biggest architectural quirk into the best feature.
|
||||||
|
|
||||||
|
Do **not** build a checkpoint browser. Mention rewind (`Esc Esc`) in Help and stop there.
|
||||||
|
|
||||||
|
**2. Library — the MCP tab's successor.** The pattern was already invented three times:
|
||||||
|
global MCP servers with per-project checkboxes, global Claude instructions, and Mission
|
||||||
|
Control's bundled skill install. Generalize it once: a Library of **skills, agents, and
|
||||||
|
slash commands** defined globally with per-project enable, synced into the container's
|
||||||
|
`.claude` volume by the entrypoint. Across many projects, "write a skill once, enable it in
|
||||||
|
twelve sandboxes" is genuinely differentiated. Keep the editor minimal — name plus markdown
|
||||||
|
textarea, or "import from folder." Not a structured form per frontmatter field.
|
||||||
|
|
||||||
|
**3. Permission mode as the hero control.** The whole pitch is "sandbox so you can safely
|
||||||
|
go fast," yet that pitch is expressed as a scary boolean buried in a config accordion.
|
||||||
|
Replace it with Claude Code's real vocabulary — a segmented control (**Plan / Default /
|
||||||
|
Accept Edits / Bypass**) on Overview, echoed as a badge on terminal tabs, with sandbox
|
||||||
|
state beside it. When sandbox is ON, Bypass loses its red paint ("contained by sandbox");
|
||||||
|
when sandbox is OFF *and* Bypass is on, that is when caution color earns its place. This
|
||||||
|
reframes the product's core value in the product's own UI.
|
||||||
|
|
||||||
|
**4. Automation tab.** `triple-c-scheduler` ships in every container with
|
||||||
|
add/list/logs/notifications — and its only UI is a CLAUDE.md paragraph telling Claude to
|
||||||
|
run it. Wrap it: task list (name, cron, last run, enabled), toggle/run-now/view-log, and a
|
||||||
|
notification badge on the project row. "Your nightly agent left you a note" is a reason to
|
||||||
|
open the app in the morning. Fleet-of-scheduled-agents management across projects is
|
||||||
|
something the Claude Code TUI does not offer.
|
||||||
|
|
||||||
|
**Explicitly skip:** status line builder, output-styles editor, hook *editors* (surface the
|
||||||
|
count, deep-link to the terminal), checkpoint browser, marketplace browser. Each is niche,
|
||||||
|
natively handled, or a settings-editor trap.
|
||||||
|
|
||||||
|
### B4. Coherence test
|
||||||
|
|
||||||
|
Every screen answers exactly one question:
|
||||||
|
|
||||||
|
| Screen | Question |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar | What projects exist and are they up? |
|
||||||
|
| Project Home | What can this sandbox do, and where did I leave off? |
|
||||||
|
| Terminal | Do the work. |
|
||||||
|
| Library | What capabilities do I reuse? |
|
||||||
|
| Settings | How does the host behave? |
|
||||||
|
|
||||||
|
Anything that doesn't answer one of those doesn't get a nav slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorities
|
||||||
|
|
||||||
|
### Tier 1 — high impact, cheap
|
||||||
|
|
||||||
|
1. `:focus-visible` ring and stop stripping outlines (one CSS rule + token). Add
|
||||||
|
`Ctrl+T` / `Ctrl+W` / `Ctrl+1..9` / `Ctrl+Tab`.
|
||||||
|
2. Contrast: `--accent-emphasis: #1f6feb` for filled buttons; kill white-on-`#3fb950`;
|
||||||
|
`--text-disabled` instead of `opacity-50`.
|
||||||
|
3. Real buttons for project actions; Remove into an overflow menu; primary action filled.
|
||||||
|
4. Inline start/stop progress and an error toast; delete `ContainerProgressModal`.
|
||||||
|
5. Status dots get labels or shapes; Docker-down turns red; null state pulses.
|
||||||
|
6. Welcome screen becomes an onboarding checklist with a real button, plus the logo.
|
||||||
|
7. One shared `<Modal>` with focus trap and ARIA for the modals that remain.
|
||||||
|
8. Permission-mode segmented control replacing the boolean.
|
||||||
|
9. `lucide-react` icons; move the tab strip onto the terminal panel.
|
||||||
|
|
||||||
|
### Tier 2 — high impact, expensive
|
||||||
|
|
||||||
|
1. **Project Home tabbed view** — the structural fix that dissolves the modal pile and the
|
||||||
|
1,257-line ProjectCard. The forms already exist; this is mostly moving and splitting.
|
||||||
|
2. **Sessions tab** with `claude --resume`.
|
||||||
|
3. **Library** — generalize global→per-project sync to skills/agents/commands.
|
||||||
|
4. **Automation tab** wrapping `triple-c-scheduler`, with notification badges.
|
||||||
|
|
||||||
|
### Tier 3 — skip
|
||||||
|
|
||||||
|
- Light theme (dark-only is right; tokens keep the door open).
|
||||||
|
- Editors for hooks, statusline, output styles; checkpoint browser; marketplace browser.
|
||||||
|
- Any new global sidebar tab beyond Library.
|
||||||
|
- Rebuilding MCP management in any form. Let the deletion be a lesson, not a vacancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**One sentence:** promote the project from a sidebar card to a first-class workspace view,
|
||||||
|
use the volume you already own to surface sessions/capabilities/automation instead of
|
||||||
|
building config editors, and spend a focused week on focus rings, contrast, and button
|
||||||
|
affordances — the visual layer needs sanding, not redesign.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Mission Control Setup Instructions
|
||||||
|
|
||||||
|
Reference document for adding Flight Control methodology to any project.
|
||||||
|
|
||||||
|
## How Triple-C Installs Mission Control
|
||||||
|
|
||||||
|
When Mission Control is enabled for a project in Triple-C:
|
||||||
|
|
||||||
|
1. **Bundled files install**: The Mission Control files bundled with Triple-C are copied to `/home/claude/mission-control/` (persisted in the config volume)
|
||||||
|
2. **Skills install**: All skills from the bundled `.claude/skills/` are copied to `~/.claude/skills/` so Claude Code discovers them automatically as `/slash-commands`
|
||||||
|
3. **Workspace symlink**: `/workspace/mission-control/` symlinks to the installed copy for methodology doc access
|
||||||
|
4. **Global instructions**: Mission Control usage instructions are injected into `~/.claude/CLAUDE.md`
|
||||||
|
|
||||||
|
This happens automatically on every container start, so skill updates from new Triple-C releases are picked up on restart.
|
||||||
|
|
||||||
|
## Two pieces are needed per project:
|
||||||
|
|
||||||
|
1. **Global CLAUDE.md** — Handled automatically by Triple-C when Mission Control is enabled
|
||||||
|
2. **Project CLAUDE.md** — Add the Flight Operations section to each project's `CLAUDE.md`
|
||||||
|
|
||||||
|
Then run `/init-project` to create the `.flightops/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Global CLAUDE.md Instructions
|
||||||
|
|
||||||
|
These are **automatically injected by Triple-C** when Mission Control is enabled. For reference, the injected content is:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Mission Control
|
||||||
|
|
||||||
|
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
|
||||||
|
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
|
||||||
|
- The methodology docs and project registry live in `/workspace/mission-control/`
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
|
||||||
|
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
|
||||||
|
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
|
||||||
|
3. Use Mission Control skills for planning and execution
|
||||||
|
|
||||||
|
### Available Skills
|
||||||
|
|
||||||
|
| Skill | When to Use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/init-project` | Setting up a new project for Flight Control |
|
||||||
|
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
|
||||||
|
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
|
||||||
|
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
|
||||||
|
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
|
||||||
|
| `/flight-debrief` | Post-flight analysis after a flight lands |
|
||||||
|
| `/mission-debrief` | Post-mission retrospective after completion |
|
||||||
|
| `/daily-briefing` | Cross-project status report |
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
|
||||||
|
- **Planning skills produce artifacts only** — never modify source code directly
|
||||||
|
- **Phase gates require human confirmation** — missions before flights, flights before legs
|
||||||
|
- **Legs are immutable once in-flight** — create new ones instead of modifying
|
||||||
|
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
|
||||||
|
- **Artifacts live in the target project** — not in mission-control
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project CLAUDE.md Instructions
|
||||||
|
|
||||||
|
Add this section to each project's `CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Flight Operations
|
||||||
|
|
||||||
|
This project uses Flight Control (bundled with Triple-C) for structured development.
|
||||||
|
|
||||||
|
**Before any mission/flight/leg work, read these files in order:**
|
||||||
|
1. `.flightops/README.md` — What the flightops directory contains
|
||||||
|
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
|
||||||
|
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||||
|
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Initialize the Project
|
||||||
|
|
||||||
|
After adding the CLAUDE.md sections, run `/init-project` from mission-control to:
|
||||||
|
|
||||||
|
1. Create the `.flightops/` directory with methodology references
|
||||||
|
2. Configure the artifact system (files or Jira)
|
||||||
|
3. Set up agent crew definitions
|
||||||
|
4. Register the project in `/workspace/mission-control/projects.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Checklist for New Projects
|
||||||
|
|
||||||
|
- [ ] Enable Mission Control for the project in Triple-C (auto-installs skills to `~/.claude/skills/`)
|
||||||
|
- [ ] Add Flight Operations section to the project's `CLAUDE.md`
|
||||||
|
- [ ] Run `/init-project` from mission-control
|
||||||
|
- [ ] Add the project to `/workspace/mission-control/projects.md`
|
||||||
|
- [ ] Add `.flightops/` to the project's `.gitignore` (if artifacts should not be committed) or commit it (if they should)
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||||
|
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
# Triple-C (Claude-Code-Container)
|
||||||
|
|
||||||
|
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||||
|
|
||||||
|
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||||
|
|
||||||
|
| Document | For |
|
||||||
|
|---|---|
|
||||||
|
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||||
|
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||||
|
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||||
|
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||||
|
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||||
|
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||||
|
- [Permission Modes](#permission-modes)
|
||||||
|
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||||
|
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||||
|
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||||
|
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||||
|
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||||
|
- **Backend**: Rust (Tauri v2 framework)
|
||||||
|
- **Terminal**: xterm.js with WebGL rendering
|
||||||
|
- **Docker API**: bollard (pure Rust Docker client)
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar (MainTabs strip + Docker/Image status + ?) │
|
||||||
|
├────────────┬────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ Main Content │
|
||||||
|
│ (25% w, │ · Project Home views, or │
|
||||||
|
│ responsive│ · terminal views (xterm.js) │
|
||||||
|
│ min/max) │ │
|
||||||
|
├────────────┴────────────────────────────────────────┤
|
||||||
|
│ StatusBar (project/terminal counts, STT, scroll) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The main area is driven by **one ordered tab strip** (`components/layout/MainTabs.tsx`) holding
|
||||||
|
two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a terminal). There is no
|
||||||
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
|
thing is current at a time.
|
||||||
|
|
||||||
|
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||||
|
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||||
|
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||||
|
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||||
|
carries dropped *file paths*.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+T` | New Claude terminal for the current project (no-op unless it is running) |
|
||||||
|
| `Ctrl+Shift+W` | Close the active tab |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
|
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||||
|
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||||
|
|
||||||
|
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||||
|
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||||
|
why moving a tab takes Shift as well.
|
||||||
|
|
||||||
|
Terminal-scoped keys are handled in `TerminalView.tsx`:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is |
|
||||||
|
| `Ctrl+Shift+M` | Toggle speech-to-text recording |
|
||||||
|
| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting |
|
||||||
|
| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked |
|
||||||
|
|
||||||
|
`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for
|
||||||
|
VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes
|
||||||
|
are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for
|
||||||
|
devices with no Shift.
|
||||||
|
|
||||||
|
### Project Home
|
||||||
|
|
||||||
|
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||||
|
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||||
|
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||||
|
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
|
| Tab | Contents |
|
||||||
|
|---|---|
|
||||||
|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||||
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
|
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||||
|
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||||
|
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||||
|
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||||
|
|
||||||
|
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||||
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
|
progress modal.
|
||||||
|
|
||||||
|
## Permission Modes
|
||||||
|
|
||||||
|
`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` |
|
||||||
|
|---|---|---|
|
||||||
|
| **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
|
||||||
|
the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing
|
||||||
|
the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it
|
||||||
|
was launched with.
|
||||||
|
|
||||||
|
Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via
|
||||||
|
`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint
|
||||||
|
snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it
|
||||||
|
back into flags for its headless `claude -p` run. Because it travels as container env, a mode change
|
||||||
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
|
forces that).
|
||||||
|
|
||||||
|
## Containers
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||||
|
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||||
|
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||||
|
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||||
|
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||||
|
|
||||||
|
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||||
|
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||||
|
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||||
|
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||||
|
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||||
|
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||||
|
image.
|
||||||
|
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||||
|
|
||||||
|
### Base-Image Migration
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||||
|
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||||
|
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||||
|
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||||
|
|
||||||
|
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||||
|
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||||
|
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||||
|
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||||
|
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||||
|
"stale".
|
||||||
|
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||||
|
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||||
|
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||||
|
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||||
|
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||||
|
skills, transcripts and scheduler tasks simply re-attach.
|
||||||
|
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||||
|
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||||
|
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||||
|
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||||
|
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||||
|
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||||
|
report.
|
||||||
|
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||||
|
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||||
|
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||||
|
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||||
|
`$HOME` during a migrated session survives it.
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
| Target in Container | Source | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||||
|
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||||
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
|
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||||
|
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||||
|
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||||
|
removal, and by nothing else.
|
||||||
|
|
||||||
|
### Corporate CA Certificates
|
||||||
|
|
||||||
|
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||||
|
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||||
|
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||||
|
every start — so it survives recreation, migration and Reset.
|
||||||
|
|
||||||
|
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||||
|
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||||
|
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||||
|
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||||
|
sees a directory.
|
||||||
|
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||||
|
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||||
|
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||||
|
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||||
|
rather than failing the start.
|
||||||
|
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||||
|
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||||
|
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||||
|
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||||
|
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||||
|
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||||
|
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||||
|
so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
|
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||||
|
|
||||||
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
|
### Docker Socket Path
|
||||||
|
|
||||||
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
|
## Models and Authentication
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
Each project can independently use one of:
|
||||||
|
|
||||||
|
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||||
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
|
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||||
|
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||||
|
|
||||||
|
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||||
|
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||||
|
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||||
|
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||||
|
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||||
|
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||||
|
> they get first-class backends of their own rather than going through a translation layer.
|
||||||
|
|
||||||
|
#### Model alias variables
|
||||||
|
|
||||||
|
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||||
|
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||||
|
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||||
|
|
||||||
|
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||||
|
Triple-C therefore sets all four:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||||
|
|
||||||
|
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||||
|
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||||
|
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||||
|
|
||||||
|
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||||
|
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||||
|
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||||
|
|
||||||
|
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
|
|
||||||
|
### Model Gateway (LiteLLM sibling container)
|
||||||
|
|
||||||
|
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||||
|
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||||
|
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||||
|
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||||
|
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||||
|
lifecycle, including auto-start with the app.
|
||||||
|
|
||||||
|
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||||
|
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||||
|
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||||
|
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||||
|
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||||
|
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||||
|
**always** set, because LiteLLM without one accepts any key.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||||
|
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||||
|
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||||
|
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||||
|
anything resembling an `sk-ant-` secret.
|
||||||
|
|
||||||
|
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||||
|
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||||
|
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||||
|
|
||||||
|
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||||
|
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||||
|
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||||
|
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||||
|
it cleared.
|
||||||
|
|
||||||
|
## Bridges to the Host
|
||||||
|
|
||||||
|
### URL Relay (host browser)
|
||||||
|
|
||||||
|
There is no browser and no display inside the container, so any CLI that wants to open a web page
|
||||||
|
— `gh auth login`, `aws sso login`, `gcloud auth login`, `az login`, vendor CLIs, `xdg-open`,
|
||||||
|
Python's `webbrowser` — simply fails. The URL relay forwards the *request* to the host, where the
|
||||||
|
user's real browser is. Nothing is rendered or forwarded from the container; only the URL travels.
|
||||||
|
It complements the Auth Bridge below: the relay gets the login page open, the bridge lets the
|
||||||
|
callback land.
|
||||||
|
|
||||||
|
**Transport — an OSC escape sequence, following `osc52-clipboard`.** `container/triple-c-open`
|
||||||
|
writes
|
||||||
|
|
||||||
|
```
|
||||||
|
ESC ] 7777 ; open ; <base64(url)> BEL
|
||||||
|
```
|
||||||
|
|
||||||
|
to **`/dev/tty`**, and `TerminalView.tsx` picks it up with `term.parser.registerOscHandler(7777, …)`.
|
||||||
|
`/dev/tty` rather than stdout is the whole point: the shim usually runs as a grandchild of
|
||||||
|
something that captures its children's output (Claude Code invoking `gh auth login` as a tool
|
||||||
|
call), so a printed sentinel line — the `###TRIPLE_C_SSO_REFRESH###` approach — would be swallowed
|
||||||
|
by the intermediate process and never reach the terminal. A control sequence on the controlling
|
||||||
|
terminal always arrives, and is invisible to terminals that don't know it. Base64 keeps a `;`,
|
||||||
|
`BEL` or `ESC` inside the URL from breaking out of the sequence.
|
||||||
|
|
||||||
|
**Container side** — `container/triple-c-open`, installed as `xdg-open`, `sensible-browser`,
|
||||||
|
`www-browser`, `x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open`, and exported as
|
||||||
|
`$BROWSER`. Ubuntu 24.04 ships a real `/usr/bin/sensible-browser` (from `sensible-utils`), so that
|
||||||
|
one is `dpkg-divert`ed rather than merely shadowed by a `/usr/local/bin` symlink; `www-browser` and
|
||||||
|
`x-www-browser` are registered through `update-alternatives` and pinned with `--set`, because
|
||||||
|
`sensible-browser` probes them by absolute path and because a later `apt install firefox` must not
|
||||||
|
be able to steal them. `xdg-open` is diverted pre-emptively so installing `xdg-utils` inside the
|
||||||
|
container cannot displace the relay. `BROWSER` is an image-level `ENV` — terminal sessions are
|
||||||
|
separate `docker exec`s and never see what the entrypoint exported — and the entrypoint also
|
||||||
|
forwards it into the scheduler's cron environment file.
|
||||||
|
|
||||||
|
**No terminal attached** (cron-driven scheduled tasks, or a plain `docker exec` from outside
|
||||||
|
Triple-C): there is no handshake and nothing to wait for, so the shim never blocks. The write to
|
||||||
|
`/dev/tty` fails, and it prints the URL in plain text on its own line and exits 0 — which lands in
|
||||||
|
the scheduler task log where a human can still act on it.
|
||||||
|
|
||||||
|
**Security posture — the container is the untrusted side.** `app/src/lib/urlRelay.ts` validates
|
||||||
|
before anything reaches `openUrl`: `http:`/`https:` only (`file:`, `javascript:`, `data:` and every
|
||||||
|
registered protocol handler rejected), no embedded credentials, no control characters or
|
||||||
|
whitespace, length-capped, and returned WHATWG-normalized so the prompt shows exactly what will
|
||||||
|
open. Nothing opens automatically — the user confirms in the existing `UrlToast`, and prompts are
|
||||||
|
rate-limited (5 per 10 s, repeats of the same URL collapsed) so a loop in the container cannot bury
|
||||||
|
the UI.
|
||||||
|
|
||||||
|
**Web terminal** — deliberately *not* a copy of the desktop behaviour. The browser there belongs to
|
||||||
|
a remote viewer, possibly on a phone across a tunnel, so `terminal.html` renders the relayed URL as
|
||||||
|
a tap-to-open link banner with the same scheme allowlist and rate limit, and opens nothing by
|
||||||
|
itself. The OSC handler is registered regardless so the sequence is consumed rather than painted as
|
||||||
|
garbage.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
|
||||||
|
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
|
||||||
|
browser's redirect to reach it. `auth_bridge/` closes that gap:
|
||||||
|
|
||||||
|
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
|
||||||
|
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
|
||||||
|
binds are deliberately ignored (that is the port-mappings feature's job).
|
||||||
|
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
|
||||||
|
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
|
||||||
|
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
|
||||||
|
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
|
||||||
|
IPs are not routable from the host on Docker Desktop.
|
||||||
|
- Ports already covered by the project's port mappings are skipped, and a host port that is already
|
||||||
|
in use is reported as a conflict rather than fought over.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
|
||||||
|
recreates the container. The poller stops on its own when the container stops.
|
||||||
|
|
||||||
|
**Security posture:** the host side binds loopback only. Everything reachable through it is an
|
||||||
|
unauthenticated service inside the container, so widening those addresses would publish container
|
||||||
|
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||||
|
|
||||||
|
### Browser View
|
||||||
|
|
||||||
|
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||||
|
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||||
|
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||||
|
per project.
|
||||||
|
|
||||||
|
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||||
|
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||||
|
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||||
|
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||||
|
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||||
|
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||||
|
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||||
|
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||||
|
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||||
|
iframe while popped out, and both viewers can drive the browser.
|
||||||
|
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||||
|
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||||
|
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||||
|
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||||
|
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||||
|
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||||
|
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||||
|
continuously and each event costs a container exec.
|
||||||
|
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||||
|
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||||
|
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||||
|
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||||
|
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||||
|
recreation *and* migration.
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||||
|
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||||
|
once Playwright is present — not a setup route.
|
||||||
|
|
||||||
|
### Host File Transfers
|
||||||
|
|
||||||
|
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||||
|
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||||
|
policy in `commands/file_commands.rs`.
|
||||||
|
|
||||||
|
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||||
|
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||||
|
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||||
|
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||||
|
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||||
|
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||||
|
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||||
|
canonical ones included, so this closes the inbound direction and not both.
|
||||||
|
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||||
|
save dialog parses its name box as a path, and a container can name a file
|
||||||
|
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||||
|
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||||
|
cannot be a path on any platform this ships to.
|
||||||
|
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||||
|
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||||
|
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||||
|
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||||
|
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||||
|
it, not an errno.
|
||||||
|
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||||
|
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||||
|
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||||
|
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||||
|
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||||
|
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||||
|
Terminal tab keeps the one it has.
|
||||||
|
|
||||||
|
## Inside a Project
|
||||||
|
|
||||||
|
### Container Introspection (Capability Tiles)
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
|
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||||
|
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||||
|
|
||||||
|
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||||
|
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||||
|
|
||||||
|
### Mission Control Integration
|
||||||
|
|
||||||
|
Optional per-project integration with Flight Control — an AI-first development methodology bundled with Triple-C. When enabled, the bundled files are installed into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
||||||
|
|
||||||
|
### Web Terminal (Remote Access)
|
||||||
|
|
||||||
|
Triple-C includes an optional web terminal server for accessing project terminals from tablets, phones, or other devices on the local network. When enabled in Settings, an axum HTTP+WebSocket server starts inside the Tauri process, serving a standalone xterm.js-based terminal UI.
|
||||||
|
|
||||||
|
- **URL**: `http://<LAN_IP>:7681?token=...` (port configurable)
|
||||||
|
- **Authentication**: Token-based (auto-generated, copyable from Settings)
|
||||||
|
- **Protocol**: JSON over WebSocket with base64-encoded terminal data
|
||||||
|
- **Features**: Project picker, multiple tabs (Claude + bash sessions), mobile-optimized input bar, scroll-to-bottom button
|
||||||
|
- **Session cleanup**: All terminal sessions are closed when the browser disconnects
|
||||||
|
|
||||||
|
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
|
||||||
|
|
||||||
|
### Speech-to-Text (Voice Mode)
|
||||||
|
|
||||||
|
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active.
|
||||||
|
|
||||||
|
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||||
|
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||||
|
- **Port**: Default `9876` (configurable)
|
||||||
|
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||||
|
- **Language**: Optional language hint for transcription
|
||||||
|
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||||
|
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||||
|
|
||||||
|
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
### Frontend — layout and projects
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||||
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||||
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), 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, 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 / 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 |
|
||||||
|
|
||||||
|
### Frontend — Project Home
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||||
|
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||||
|
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||||
|
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||||
|
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||||
|
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||||
|
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||||
|
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||||
|
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||||
|
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||||
|
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||||
|
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
|
||||||
|
|
||||||
|
### Frontend — settings, terminal and hooks
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||||
|
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||||
|
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||||
|
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||||
|
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||||
|
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||||
|
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
|
||||||
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||||
|
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||||
|
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||||
|
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||||
|
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||||
|
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||||
|
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
|
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||||
|
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||||
|
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||||
|
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||||
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
|
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||||
|
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||||
|
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||||
|
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||||
|
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||||
|
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||||
|
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||||
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||||
|
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||||
|
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||||
|
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||||
|
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||||
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
|
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||||
|
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||||
|
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||||
|
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||||
|
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||||
|
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||||
|
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||||
|
|
||||||
|
### Container and packaging
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||||
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||||
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
|
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||||
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
|
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||||
|
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||||
|
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||||
|
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||||
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
|
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||||
|
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||||
|
|
||||||
|
## CSS / Styling Notes
|
||||||
|
|
||||||
|
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
|
||||||
|
- All colors use CSS custom properties defined in `index.css` `:root`
|
||||||
|
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
|
||||||
|
|
||||||
|
## Container Image
|
||||||
|
|
||||||
|
**Base**: Ubuntu 24.04
|
||||||
|
|
||||||
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||||
|
|
||||||
|
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||||
|
|
||||||
|
**Browser runtime libraries**: the shared libraries Chromium links against (`libnss3`, `libgbm1`,
|
||||||
|
`libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, … plus fonts) are baked in, via
|
||||||
|
`npx playwright install-deps chromium` at build time. Without them `playwright install chromium`
|
||||||
|
downloads a browser that then dies at launch with *"Host system is missing dependencies:
|
||||||
|
libnss3.so"* — which is why installing `google-chrome-stable` used to look like the fix (apt was
|
||||||
|
pulling the libraries in as *its* dependencies). Measured cost of the layer: +99 packages,
|
||||||
|
**+334 MiB unpacked / +119 MiB compressed** (2950 → 3284 MiB unpacked, 759 → 878 MiB compressed).
|
||||||
|
Two thirds of that is not avoidable by trimming — `libgbm1`, which Chromium needs, depends on
|
||||||
|
`mesa-libgallium`, which depends on `libllvm20`. The list is taken from Playwright rather than
|
||||||
|
hand-written so it cannot rot against Ubuntu 24.04's `t64` renames or a future Chromium dependency,
|
||||||
|
and the `install-deps --dry-run` that follows it is a build-time assertion: on a platform
|
||||||
|
Playwright has no list for, `install-deps` installs nothing and still exits 0.
|
||||||
|
|
||||||
|
**Browser binaries are deliberately not baked.** They are large, they are version-coupled to
|
||||||
|
whatever Playwright the user installs, and they already persist: `~/.cache/ms-playwright` is inside
|
||||||
|
the home volume, so a downloaded browser survives container recreation *and* base-image migration.
|
||||||
|
The libraries are the opposite — a runtime `apt-get install` lands in the container's writable
|
||||||
|
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
||||||
|
against the new base). Baking one and not the other puts each half where it already persists.
|
||||||
|
|
||||||
|
**`/home/claude` in the image is seed-only.** It is the mount point of the `triple-c-home-{projectId}`
|
||||||
|
volume, so after a project's *first* start the image's copy of that directory is masked permanently.
|
||||||
|
A change made under `/home/claude` in the Dockerfile reaches **new projects only** — with or without
|
||||||
|
a base-image migration. Anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or
|
||||||
|
must be seeded by `entrypoint.sh` on every start.
|
||||||
|
|
||||||
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Triple-C Roadmap — Claude Code Feature Parity
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226
|
||||||
|
|
||||||
|
Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and
|
||||||
|
information architecture. This document covers *which Claude Code capabilities Triple-C
|
||||||
|
should surface, and why.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding principle
|
||||||
|
|
||||||
|
> **Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Triple-C's built-in MCP server management was removed in this cycle because Claude Code
|
||||||
|
absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`).
|
||||||
|
Hooks, skills, agents, plugins, output styles, and statusline are the same species: files
|
||||||
|
under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them
|
||||||
|
means losing the same race again.
|
||||||
|
|
||||||
|
What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled
|
||||||
|
scheduler, and the fleet view across many projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current coverage (v0.3.0)
|
||||||
|
|
||||||
|
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
|
||||||
|
|
||||||
|
| Key | Surfaced as |
|
||||||
|
|---|---|
|
||||||
|
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||||
|
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||||
|
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||||
|
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||||
|
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
|
||||||
|
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
|
||||||
|
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||||
|
|
||||||
|
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
|
||||||
|
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
|
||||||
|
merges into it, so a key merely omitted when its control goes off left the previous
|
||||||
|
on-value in place forever.
|
||||||
|
|
||||||
|
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||||
|
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||||
|
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
|
||||||
|
create *including* their off value, because `docker commit` bakes a container's env into
|
||||||
|
the snapshot image: a value written once would otherwise ride that snapshot into every
|
||||||
|
future container. That also makes them Triple-C's to own, so all four are reserved names
|
||||||
|
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
|
||||||
|
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
|
||||||
|
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
|
||||||
|
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
|
||||||
|
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
|
||||||
|
choice it never asked about.
|
||||||
|
|
||||||
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
|
container start *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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap analysis
|
||||||
|
|
||||||
|
### Committed for this cycle
|
||||||
|
|
||||||
|
| # | Gap | Today | Plan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. |
|
||||||
|
| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume <id>`. |
|
||||||
|
| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. |
|
||||||
|
| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. |
|
||||||
|
| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. |
|
||||||
|
|
||||||
|
### Deliberately skipped
|
||||||
|
|
||||||
|
Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser ·
|
||||||
|
plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a
|
||||||
|
settings-editor trap. Surface counts and deep-link instead.
|
||||||
|
|
||||||
|
### Not yet scheduled
|
||||||
|
|
||||||
|
- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories`
|
||||||
|
- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`,
|
||||||
|
`excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS`
|
||||||
|
- Project-level `.claude/settings.json` vs user-level settings hierarchy
|
||||||
|
- A model picker. **Note:** the only model strings in the app today are stale placeholders
|
||||||
|
(`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`,
|
||||||
|
`qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not
|
||||||
|
dropdowns, but they should be refreshed to current model identifiers regardless.
|
||||||
|
- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a
|
||||||
|
user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale
|
||||||
|
on every container start. Worth deepening to `*` recursive merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication handoff
|
||||||
|
|
||||||
|
**Goal:** stop making users hand-copy an auth code into every container.
|
||||||
|
|
||||||
|
**Constraint discovered during research:** `claude login`'s callback server uses an
|
||||||
|
**ephemeral port** and its redirect URI is **not configurable** for the main login flow
|
||||||
|
(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design
|
||||||
|
that pre-assigns each container a fixed callback port and routes to it cannot work as
|
||||||
|
stated — there is no fixed port to route.
|
||||||
|
|
||||||
|
There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first,
|
||||||
|
so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
|
||||||
|
Two viable options:
|
||||||
|
|
||||||
|
### Option A — long-lived token injection (simple)
|
||||||
|
|
||||||
|
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
|
||||||
|
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
|
||||||
|
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
|
||||||
|
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
|
||||||
|
|
||||||
|
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
|
||||||
|
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
|
||||||
|
under a pty: its `redirect_uri` is Anthropic-hosted
|
||||||
|
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
|
||||||
|
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
|
||||||
|
is therefore mandatory — the flow cannot complete without one.
|
||||||
|
|
||||||
|
- No routing, no ports, no proxy.
|
||||||
|
- One auth event covers every project.
|
||||||
|
- Cost: small. Reuses existing keychain and env-injection plumbing.
|
||||||
|
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
|
||||||
|
token cannot drive Remote Control sessions or claude.ai connector fetches.
|
||||||
|
|
||||||
|
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
|
||||||
|
label, not a hash of the token. Labels are readable by anything that can run
|
||||||
|
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
|
||||||
|
token you could confirm it. A presence boolean would instead miss rotations and silently
|
||||||
|
leave containers on a stale token.
|
||||||
|
|
||||||
|
### Option B — the Auth Bridge (general loopback-callback bridge)
|
||||||
|
|
||||||
|
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
|
||||||
|
starting a temporary loopback listener and opening a browser at a URL that redirects back
|
||||||
|
to it — Concourse `fly login` (random loopback port serving `/auth/callback`),
|
||||||
|
`aws sso login`, and many others. Inside a container the host browser cannot reach that
|
||||||
|
listener, so login stalls.
|
||||||
|
|
||||||
|
Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge
|
||||||
|
**discovers** listeners instead:
|
||||||
|
|
||||||
|
1. While enabled for a running project, poll the container for loopback TCP listeners by
|
||||||
|
reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on
|
||||||
|
`ss`/`netstat`/`lsof`, which aren't guaranteed in the image.
|
||||||
|
2. For each newly-appeared loopback listener, bind **the same port on the host's
|
||||||
|
`127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN).
|
||||||
|
3. Proxy each accepted connection into the container over the Docker API via
|
||||||
|
`socat - TCP:127.0.0.1:<port>` (socat already ships in the image), reusing the existing
|
||||||
|
attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a
|
||||||
|
container IP keeps this working on Docker Desktop, where container IPs are not routable
|
||||||
|
from the host.
|
||||||
|
4. Fall back to `TCP6:[::1]:<port>` when the listener appeared only on IPv6 — on Linux,
|
||||||
|
Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or
|
||||||
|
the app exits. Ports already covered by the project's explicit port mappings are skipped;
|
||||||
|
host-side conflicts are reported rather than silently swallowed.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal
|
||||||
|
loopback services reachable from the host.
|
||||||
|
|
||||||
|
**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case
|
||||||
|
at a fraction of the cost — and **B** as the general mechanism covering every other CLI.
|
||||||
|
They compose: A means most users never trigger a browser login at all; B catches AWS SSO,
|
||||||
|
Concourse, and anything else that needs a real callback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing
|
||||||
|
migration for containers created against the old per-project Docker network.
|
||||||
|
|
||||||
|
**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix
|
||||||
|
below). Read-only introspection backend: sessions, capabilities, scheduler.
|
||||||
|
|
||||||
|
**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop
|
||||||
|
progress, status labels, onboarding welcome screen, shared accessible `<Modal>`.
|
||||||
|
|
||||||
|
**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed
|
||||||
|
main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and
|
||||||
|
splitting the 1,257-line `ProjectCard`.
|
||||||
|
|
||||||
|
**Phase 4 — authentication handoff.** Option A, then evaluate B.
|
||||||
|
|
||||||
|
**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into
|
||||||
|
the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs found during this review
|
||||||
|
|
||||||
|
1. **Scheduled tasks ignore the project's permission setting.**
|
||||||
|
`container/triple-c-task-runner:69` runs
|
||||||
|
`claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the
|
||||||
|
project's Full Permissions toggle. Being fixed as part of Phase 1.
|
||||||
|
|
||||||
|
2. **Docs claim Reset preserves credentials; it does not.**
|
||||||
|
`rebuild_project_container` calls `remove_project_volumes`, which deletes both
|
||||||
|
`triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}`
|
||||||
|
(holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that
|
||||||
|
OAuth tokens survive a Reset. Pre-existing; not yet corrected.
|
||||||
|
|
||||||
|
3. **An invalid cron expression silently unscheduled every task.** Found while adding
|
||||||
|
task creation to the Automation tab, and the most serious bug in this review.
|
||||||
|
`triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the
|
||||||
|
*entire* crontab and pipes it to `crontab`, which rejects the whole file if any single
|
||||||
|
line is malformed — with the error discarded by `2>/dev/null || true`. So one bad
|
||||||
|
schedule silently unscheduled every other task in the container, reporting success.
|
||||||
|
Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use
|
||||||
|
this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the
|
||||||
|
expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead
|
||||||
|
of swallowing it. The Rust `add_scheduled_task` command validates independently.
|
||||||
|
|
||||||
|
4. **Reset was destructive with no confirmation.** It deletes both volumes — the login,
|
||||||
|
installed skills, all session transcripts — from a single unconfirmed click, while the
|
||||||
|
comparably destructive Remove already confirmed. Now gated by a dialog that names each
|
||||||
|
loss. Fixed.
|
||||||
|
|
||||||
|
5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above.
|
||||||
|
|
||||||
|
6. **Stale model placeholders** — see "Not yet scheduled" above.
|
||||||
|
|
||||||
|
7. **Silent save failures.** Project config saves on blur; failures went only to
|
||||||
|
`console.error`. Fixed in Phase 3 — `useProjectSave` now renders a
|
||||||
|
Saved / Saving / Save failed indicator and raises a toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps left by Phase 2–3
|
||||||
|
|
||||||
|
- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit`
|
||||||
|
subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is
|
||||||
|
implemented as add-then-remove. The add runs first, so a rejected edit leaves the original
|
||||||
|
intact. The task gets a new id and its older logs stay under the old one; the editor says
|
||||||
|
so before saving.
|
||||||
|
- **`open_terminal_session` takes no command argument.** "Resume session" and
|
||||||
|
"Manage in terminal" therefore open a bash tab and *type* the command after a
|
||||||
|
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
|
||||||
|
slow container start. The fix is a `command: Option<String>` parameter on the
|
||||||
|
Tauri command so the exec launches the process directly.
|
||||||
|
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
|
||||||
|
with no start time, so Project Home records "running since" when the app *sees*
|
||||||
|
the transition. A container already running when the app launches shows
|
||||||
|
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
|
||||||
|
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
|
||||||
|
access in the build environment used for this cycle. The existing inline SVGs and
|
||||||
|
text glyphs remain.
|
||||||
|
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
|
||||||
|
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
|
||||||
|
the tabs in the TopBar; the diagram won. Worth revisiting.
|
||||||
|
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
|
||||||
|
`kill-word`, used constantly inside the terminal this app is built around;
|
||||||
|
intercepting it globally would break word-erase in every shell.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with `--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode — `--dangerously-skip-permissions` — Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,6 +57,19 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
||||||
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
||||||
|
|
||||||
|
#### Terminal Layout & StatusBar Controls
|
||||||
|
|
||||||
|
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`.
|
||||||
|
- **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.
|
||||||
|
- **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)
|
### bollard (Docker API)
|
||||||
|
|
||||||
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
||||||
@@ -100,16 +113,21 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
│ │ Project Management │◄─┤ ProjectsStore │ │
|
│ │ Project Management │◄─┤ ProjectsStore │ │
|
||||||
│ │ Settings UI │ │ bollard Docker Client │ │
|
│ │ Settings UI │ │ bollard Docker Client │ │
|
||||||
│ │ │ │ keyring Credential Mgr │ │
|
│ │ │ │ keyring Credential Mgr │ │
|
||||||
│ └───────────┬───────────┘ └────────────┬─────────────┘ │
|
│ └───────────┬───────────┘ │ Web Terminal Server │ │
|
||||||
|
│ │ └────────────┬─────────────┘ │
|
||||||
│ │ Tauri IPC (invoke/emit) │ │
|
│ │ Tauri IPC (invoke/emit) │ │
|
||||||
│ └───────────┬───────────────┘ │
|
│ └───────────┬───────────────┘ │
|
||||||
|
│ ▲ │
|
||||||
|
│ axum HTTP+WS│(port 7681) │
|
||||||
|
│ │ │
|
||||||
└──────────────────────────┼───────────────────────────────┘
|
└──────────────────────────┼───────────────────────────────┘
|
||||||
│ Docker Socket
|
│ Docker Socket
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ Docker Container (per project) │
|
│ Docker Container (per project) │
|
||||||
│ │
|
│ │
|
||||||
│ /workspace ←── bind mount ──► Host project directory │
|
│ /workspace/<name> ←─ bind mount ─► Host project folder │
|
||||||
|
│ /home/claude ←── named volume (home dir) │
|
||||||
│ /home/claude/.claude ←── named volume (persists config) │
|
│ /home/claude/.claude ←── named volume (persists config) │
|
||||||
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
||||||
│ /var/run/docker.sock ←── optional (sibling containers) │
|
│ /var/run/docker.sock ←── optional (sibling containers) │
|
||||||
@@ -129,6 +147,8 @@ The application uses two IPC mechanisms between the React frontend and Rust back
|
|||||||
|
|
||||||
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
|
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
|
||||||
|
|
||||||
|
**WebSocket Streaming** (Web Terminal): Used for remote terminal access from browsers on the local network. An axum HTTP+WebSocket server runs inside the Tauri process, sharing the same `ExecSessionManager` via `Arc`-wrapped stores. The WebSocket uses a JSON protocol with base64-encoded terminal data. Each browser connection can open multiple terminal sessions; all sessions are cleaned up when the WebSocket disconnects.
|
||||||
|
|
||||||
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
|
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
|
||||||
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
|
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
|
||||||
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
|
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
|
||||||
@@ -144,23 +164,179 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
|
|||||||
|
|
||||||
Containers follow a **stop/start** model, not create/destroy:
|
Containers follow a **stop/start** model, not create/destroy:
|
||||||
|
|
||||||
1. **First start**: A new container is created with bind mounts, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive.
|
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
|
||||||
2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
|
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
|
||||||
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
||||||
4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist.
|
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
|
||||||
5. **Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted.
|
5. **Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image.
|
||||||
|
|
||||||
The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets.
|
Two named volumes exist per project and they are the only ones it owns:
|
||||||
|
|
||||||
|
| Volume | Mount point | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `triple-c-home-{projectId}` | `/home/claude` | Home directory — `~/.claude.json`, `~/.local`, `~/.ssh`, `~/.aws` |
|
||||||
|
| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. |
|
||||||
|
|
||||||
|
`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from
|
||||||
|
exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal
|
||||||
|
passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project
|
||||||
|
removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills,
|
||||||
|
session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or
|
||||||
|
host keychain secrets.
|
||||||
|
|
||||||
|
### Permission Modes
|
||||||
|
|
||||||
|
`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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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
|
||||||
|
`None` from the legacy `full_permissions` flag, so records written before the change keep behaving
|
||||||
|
the same way.
|
||||||
|
|
||||||
|
**Interactive path.** `build_terminal_cmd()` evaluates `cli_args()` when a session is created, so
|
||||||
|
the flags are fixed for the life of that `claude` process. Changing the mode affects terminals
|
||||||
|
opened afterwards, not running ones. The same applies to `resume_session_command`, which builds
|
||||||
|
`claude <flags> --resume <id>` server-side.
|
||||||
|
|
||||||
|
**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as
|
||||||
|
`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted
|
||||||
|
variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the
|
||||||
|
value back to flags for its `claude -p` run. Container env can only change at create time, so
|
||||||
|
`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation
|
||||||
|
on the next start. A mode change therefore reaches new terminals immediately but the scheduler only
|
||||||
|
after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set.
|
||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project independently chooses one of three authentication methods:
|
Each project independently chooses one backend:
|
||||||
|
|
||||||
| Mode | How It Works | When to Use |
|
| Backend | How It Works | When to Use |
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **Login (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via the web links addon. Token persists in the `.claude` config volume. | Personal use, interactive sessions |
|
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
|
||||||
| **API Key** | Key stored in OS keychain, injected as `ANTHROPIC_API_KEY` env var at container creation. | Automated workflows, team-shared keys |
|
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
|
||||||
| **AWS Bedrock** | Per-project AWS credentials (static, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `ollama`. Ollama implements `POST /v1/messages` natively. | Local models (best-effort) |
|
||||||
|
| **llama.cpp** | `ANTHROPIC_BASE_URL` points at a `llama-server` (default port 8080); `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `llama.cpp`, which `llama-server` ignores unless started with `--api-key`. `llama-server` implements `POST /v1/messages` and `/v1/messages/count_tokens` natively. | Local models (best-effort) |
|
||||||
|
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at a gateway. **Despite the name, the endpoint must implement the Anthropic Messages API** — Claude Code only ever sends `POST /v1/messages?beta=true`, never `/v1/chat/completions`. LiteLLM works; a bare OpenAI-only server does not. | Anthropic-shaped gateways (best-effort) |
|
||||||
|
|
||||||
|
#### Model aliases on custom endpoints
|
||||||
|
|
||||||
|
`Backend::uses_custom_endpoint()` (Ollama, llama.cpp, OpenAI Compatible) gates the emission of
|
||||||
|
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL`, computed by
|
||||||
|
`docker::container::compute_model_aliases`. All four default to the backend's resolved model id;
|
||||||
|
each backend carries an optional `haiku_model_id` override, because the Haiku alias is what Claude
|
||||||
|
Code uses for background work. Anthropic and Bedrock emit none of them and keep Claude Code's
|
||||||
|
defaults; the four names are in `MANAGED_AUTH_KEYS`, so switching away from a custom endpoint
|
||||||
|
blanks the values baked into the snapshot image. The resolved alias set is folded into each
|
||||||
|
backend's `triple-c.*-fingerprint` label, since `container_needs_recreation` is label-based and
|
||||||
|
never diffs env. `ANTHROPIC_SMALL_FAST_MODEL` is deprecated and unused.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container.
|
||||||
|
Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted
|
||||||
|
page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back
|
||||||
|
in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two
|
||||||
|
concurrent logins would race to overwrite each other's keychain entry) and times out after 15
|
||||||
|
minutes.
|
||||||
|
|
||||||
|
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||||
|
frontend, never written to a log, and no command accepts or returns it.
|
||||||
|
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||||
|
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
|
||||||
|
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
|
||||||
|
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
|
||||||
|
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||||
|
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||||
|
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||||
|
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||||
|
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||||
|
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||||
|
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||||
|
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||||
|
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
|
||||||
|
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
|
||||||
|
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
|
||||||
|
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||||
|
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||||
|
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||||
|
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||||
|
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||||
|
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||||
|
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||||
|
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||||
|
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||||
|
token onto the next frame's first word.
|
||||||
|
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||||
|
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||||
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
|
value baked into a snapshot image by `docker commit` is actively cleared.
|
||||||
|
- **Rotation** — a random UUID minted on each store is mirrored into the
|
||||||
|
`triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are
|
||||||
|
readable by anything that can run `docker inspect`, and a hash would be an offline verification
|
||||||
|
oracle. A label mismatch forces container recreation on the next start, which is when a container
|
||||||
|
picks up or loses the token.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral
|
||||||
|
HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:<port>/…`
|
||||||
|
redirect. Run inside a container, that listener is unreachable from the host browser and nothing can
|
||||||
|
be pre-published at container-creation time. `auth_bridge/` bridges it at runtime:
|
||||||
|
|
||||||
|
- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every
|
||||||
|
two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`)
|
||||||
|
bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the
|
||||||
|
port-mappings feature's job.
|
||||||
|
- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node
|
||||||
|
resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The
|
||||||
|
socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are
|
||||||
|
treated as IPv4.
|
||||||
|
- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required,
|
||||||
|
`[::1]` is best-effort. **The host side binds loopback only, never a wildcard address** —
|
||||||
|
everything behind it is an unauthenticated in-container service that bound loopback precisely
|
||||||
|
because it expected to be unreachable.
|
||||||
|
- **Transport** — each accepted connection is proxied by an attached exec running
|
||||||
|
`socat - TCP:127.0.0.1:<port>`, because container IPs are not routable from the host under Docker
|
||||||
|
Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with
|
||||||
|
`tty: false` so socat's stderr is demultiplexed away from the proxied byte stream.
|
||||||
|
- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure
|
||||||
|
is recorded as a conflict and retried later rather than fought over.
|
||||||
|
- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely
|
||||||
|
host-side, so it deliberately has no container-recreation label. The poller stops itself when the
|
||||||
|
project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits
|
||||||
|
it so host ports are provably released.
|
||||||
|
|
||||||
|
### Container Introspection
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in
|
||||||
|
a running container and returns counts and item lists for skills, agents, commands, hooks, plugins
|
||||||
|
and MCP servers, across user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with
|
||||||
|
`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros
|
||||||
|
rather than an error.
|
||||||
|
|
||||||
|
The script writes nothing. Claude Code owns this configuration and has its own tooling for it
|
||||||
|
(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than
|
||||||
|
rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands
|
||||||
|
(`list_scheduled_tasks`, `get_scheduled_task_log`, `set_scheduled_task_enabled`,
|
||||||
|
`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same
|
||||||
|
module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files.
|
||||||
|
|
||||||
|
### Main-Area Tab Model
|
||||||
|
|
||||||
|
The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds,
|
||||||
|
`home:<projectId>` and `term:<sessionId>`, rendered by `components/layout/MainTabs.tsx`.
|
||||||
|
`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project
|
||||||
|
Home tab and a terminal cannot both claim focus. Project configuration is a main-area view
|
||||||
|
(`components/projects/home/`), not a modal; the sidebar row is select-only.
|
||||||
|
|
||||||
### UID/GID Remapping
|
### UID/GID Remapping
|
||||||
|
|
||||||
@@ -190,10 +366,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
|
|||||||
| Data | Storage | Location |
|
| Data | Storage | Location |
|
||||||
|------|---------|----------|
|
|------|---------|----------|
|
||||||
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
||||||
| API keys | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
| API keys and per-project secrets | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
||||||
|
| Shared Claude token + rotation id | OS keychain | Separate service entries; never on disk, never in a label |
|
||||||
| App settings | Tauri plugin-store | App data directory |
|
| App settings | Tauri plugin-store | App data directory |
|
||||||
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
||||||
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
|
| Container home directory | Named Docker volume | `triple-c-home-{projectId}` |
|
||||||
|
| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset |
|
||||||
|
|
||||||
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
||||||
|
|
||||||
@@ -213,67 +391,161 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
|||||||
|
|
||||||
```
|
```
|
||||||
triple-c/
|
triple-c/
|
||||||
├── LICENSE # MIT
|
├── README.md # Architecture overview
|
||||||
├── TECHNICAL.md # This document
|
├── TECHNICAL.md # This document
|
||||||
├── Triple-C.md # Project overview
|
├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog)
|
||||||
|
├── BUILDING.md # Build instructions
|
||||||
|
├── CLAUDE.md # Claude Code instructions
|
||||||
|
├── DESIGN-REVIEW.md # UI/UX review notes
|
||||||
|
├── ROADMAP.md # Planned work
|
||||||
│
|
│
|
||||||
├── container/
|
├── container/ # Sandbox image
|
||||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||||
│ └── entrypoint.sh # UID/GID remap, SSH setup, git config
|
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection,
|
||||||
|
│ │ # scheduler env snapshot + crontab rebuild
|
||||||
|
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
||||||
|
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
||||||
|
│ ├── triple-c-scheduler # Bash-based cron task system
|
||||||
|
│ ├── triple-c-task-runner # Cron entry point; permission mode → flags → `claude -p`
|
||||||
|
│ ├── triple-c-sso-refresh # AWS SSO session refresh helper
|
||||||
|
│ └── mission-control/ # Bundled Flight Control methodology (skills, docs, templates)
|
||||||
|
│
|
||||||
|
├── stt-container/ # Speech-to-text image
|
||||||
|
│ ├── Dockerfile # Faster Whisper (Python 3.11 + FastAPI)
|
||||||
|
│ └── server.py # POST /transcribe endpoint
|
||||||
|
│
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||||
|
│ ├── build-app-preview.yml # Preview builds
|
||||||
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
|
│ ├── build-stt.yml # Build the STT image
|
||||||
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
├── vite.config.ts # Vite bundler config
|
├── vite.config.ts # Vite bundler config
|
||||||
|
├── vitest.config.ts # Vitest (jsdom) config
|
||||||
├── index.html # HTML entry point
|
├── index.html # HTML entry point
|
||||||
│
|
│
|
||||||
├── src/ # React frontend
|
├── src/ # React frontend
|
||||||
│ ├── main.tsx # React DOM root
|
│ ├── main.tsx # React DOM root
|
||||||
│ ├── App.tsx # Top-level layout
|
│ ├── App.tsx # Top-level layout + welcome screen
|
||||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
│ ├── index.css # CSS variables, dark theme, focus ring, scrollbars
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ └── appState.ts # Zustand store (projects, sessions, UI)
|
│ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts)
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
│ │ ├── useDocker.ts # Docker status, image build
|
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||||
|
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||||
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
|
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||||
|
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||||
|
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||||
|
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||||
│ │ ├── useProjects.ts # Project CRUD operations
|
│ │ ├── useProjects.ts # Project CRUD operations
|
||||||
│ │ ├── useSettings.ts # API key, app settings
|
│ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state
|
||||||
│ │ └── useTerminal.ts # Terminal I/O, resize, session events
|
│ │ ├── useSettings.ts # App settings
|
||||||
|
│ │ ├── useSTT.ts # Speech-to-text recording and container control
|
||||||
|
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
||||||
|
│ │ ├── useUpdates.ts # App update checking
|
||||||
|
│ │ └── useVoice.ts # Voice mode audio capture
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||||
|
│ │ ├── urlDetector.ts # Long-URL reassembly for OAuth flows
|
||||||
|
│ │ ├── wav.ts # WAV encoding for STT
|
||||||
│ │ └── constants.ts # App-wide constants
|
│ │ └── constants.ts # App-wide constants
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
│ ├── DockerInstallDialog.tsx # First-run Docker setup
|
||||||
│ ├── projects/ # ProjectList, ProjectCard, AddProjectDialog
|
│ ├── layout/ # TopBar, MainTabs (the unified tab strip),
|
||||||
│ ├── terminal/ # TerminalView (xterm.js), TerminalTabs
|
│ │ # Sidebar, StatusBar, HelpDialog
|
||||||
│ ├── settings/ # ApiKeyInput, DockerSettings, AwsSettings
|
│ ├── projects/
|
||||||
│ └── containers/ # SiblingContainers
|
│ │ ├── home/ # Project Home — the main-area project view
|
||||||
|
│ │ │ ├── ProjectHome.tsx # Header, actions, overflow menu, tab strip
|
||||||
|
│ │ │ ├── OverviewTab.tsx # Permission mode, summary, recent activity
|
||||||
|
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||||
|
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||||
|
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||||
|
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||||
|
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||||
|
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||||
|
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||||
|
│ │ │ # AccessSection, RuntimeSection
|
||||||
|
│ │ ├── ProjectRow.tsx # Select-only sidebar row
|
||||||
|
│ │ ├── ProjectList.tsx # Sidebar project list
|
||||||
|
│ │ ├── AddProjectDialog.tsx # New-project dialog
|
||||||
|
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
|
||||||
|
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
|
||||||
|
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
|
||||||
|
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||||
|
│ │ # editors reused by Project Home
|
||||||
|
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||||
|
│ │ # OllamaSettings, LlamaCppSettings,
|
||||||
|
│ │ # OpenAiCompatibleSettings,
|
||||||
|
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
||||||
|
│ │ # WebTerminalSettings, SttSettings,
|
||||||
|
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
||||||
|
│ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu,
|
||||||
|
│ │ # SttButton, UrlToast, trimSelection
|
||||||
|
│ └── ui/ # Shared primitives: Modal, Button, Toggle, Field,
|
||||||
|
│ # SegmentedControl, StatusIndicator, SaveIndicator,
|
||||||
|
│ # OverflowMenu, ToastHost, Tooltip, AccordionSection
|
||||||
│
|
│
|
||||||
└── src-tauri/ # Rust backend
|
└── src-tauri/ # Rust backend
|
||||||
├── Cargo.toml # Rust dependencies
|
├── Cargo.toml # Rust dependencies
|
||||||
├── tauri.conf.json # Tauri app configuration
|
├── tauri.conf.json # Tauri app configuration
|
||||||
|
├── build.rs # Tauri build script
|
||||||
├── capabilities/
|
├── capabilities/
|
||||||
│ └── default.json # Tauri v2 permission grants
|
│ └── default.json # Tauri v2 plugin permission grants
|
||||||
└── src/
|
└── src/
|
||||||
├── lib.rs # App builder, plugin + command registration
|
├── lib.rs # App builder, plugin + command registration
|
||||||
├── main.rs # Entry point
|
├── main.rs # Entry point
|
||||||
|
├── logging.rs # Log configuration
|
||||||
├── commands/ # Tauri command handlers
|
├── commands/ # Tauri command handlers
|
||||||
│ ├── docker_commands.rs
|
│ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge
|
||||||
│ ├── project_commands.rs
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── settings_commands.rs
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ └── terminal_commands.rs
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
|
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||||
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
│ ├── project_commands.rs # Start/stop/rebuild/backup containers
|
||||||
|
│ ├── settings_commands.rs # Settings CRUD
|
||||||
|
│ ├── stt_commands.rs # STT start/stop/transcribe
|
||||||
|
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||||
|
│ ├── update_commands.rs # App update checking
|
||||||
|
│ └── web_terminal_commands.rs # Web terminal start/stop/status
|
||||||
|
├── auth_bridge/ # Host-side loopback callback bridge
|
||||||
|
│ ├── mod.rs # Per-project poller, status, lifecycle
|
||||||
|
│ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering
|
||||||
|
│ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API
|
||||||
|
├── web_terminal/ # Remote terminal access
|
||||||
|
│ ├── mod.rs # Module root
|
||||||
|
│ ├── server.rs # Axum HTTP+WS server lifecycle
|
||||||
|
│ ├── ws_handler.rs # WebSocket connection handler
|
||||||
|
│ └── terminal.html # Embedded xterm.js web UI
|
||||||
|
├── install_helper/ # Docker installation assistance
|
||||||
|
│ ├── mod.rs # Install orchestration
|
||||||
|
│ └── platform.rs # Per-OS install strategies
|
||||||
├── docker/ # Docker API layer
|
├── docker/ # Docker API layer
|
||||||
│ ├── client.rs # bollard singleton connection
|
│ ├── client.rs # bollard singleton connection
|
||||||
│ ├── container.rs # Create, start, stop, remove, inspect
|
│ ├── container.rs # Create/start/stop/remove, labels, recreation checks,
|
||||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
│ │ # remove_project_volumes, snapshot commit
|
||||||
│ ├── image.rs # Build from embedded Dockerfile, pull from registry
|
│ ├── exec.rs # create_attached_exec() — the single attached-exec path
|
||||||
│ └── sibling.rs # List non-Triple-C containers
|
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||||
|
│ ├── stt.rs # Speech-to-text container lifecycle
|
||||||
|
│ └── legacy_cleanup.rs # Migration shim for the removed MCP feature
|
||||||
├── models/ # Data structures
|
├── models/ # Data structures
|
||||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
│ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, …
|
||||||
│ └── container_config.rs
|
│ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal)
|
||||||
|
│ ├── container_config.rs # Image name resolution
|
||||||
|
│ └── update_info.rs # Update metadata
|
||||||
└── storage/ # Persistence
|
└── storage/ # Persistence
|
||||||
├── projects_store.rs # JSON file with atomic writes
|
├── projects_store.rs # JSON file with atomic writes
|
||||||
├── settings_store.rs # App settings
|
├── settings_store.rs # App settings (Tauri plugin-store)
|
||||||
└── secure.rs # OS keychain via keyring
|
└── secure.rs # OS keychain via keyring (secrets, shared token)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -297,6 +569,16 @@ triple-c/
|
|||||||
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
||||||
| `dirs` | 6.x | Cross-platform app data directory paths |
|
| `dirs` | 6.x | Cross-platform app data directory paths |
|
||||||
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
||||||
|
| `log` / `fern` | 0.4 / 0.7 | Date-based file logging |
|
||||||
|
| `include_dir` | 0.7 | Embeds the container build context in the binary |
|
||||||
|
| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads |
|
||||||
|
| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` |
|
||||||
|
| `sha2` | 0.10 | Settings fingerprints |
|
||||||
|
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
|
||||||
|
| `tower-http` | 0.6 | CORS middleware for web terminal |
|
||||||
|
| `base64` | 0.22 | Terminal data encoding over WebSocket |
|
||||||
|
| `rand` | 0.9 | Access token generation |
|
||||||
|
| `local-ip-address` | 0.6 | LAN IP detection for web terminal URL |
|
||||||
|
|
||||||
### JavaScript (Frontend)
|
### JavaScript (Frontend)
|
||||||
|
|
||||||
@@ -314,6 +596,8 @@ triple-c/
|
|||||||
| `zustand` | 5.x | Lightweight state management |
|
| `zustand` | 5.x | Lightweight state management |
|
||||||
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
||||||
| `vite` | 6.x | Frontend build tool and dev server |
|
| `vite` | 6.x | Frontend build tool and dev server |
|
||||||
|
| `vitest` | 4.x | Test runner (jsdom environment) |
|
||||||
|
| `@testing-library/react` | 16.x | Component tests |
|
||||||
|
|
||||||
### Container Image
|
### Container Image
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# TODO / Future Improvements
|
||||||
|
|
||||||
|
## In-App Auto-Update via `tauri-plugin-updater`
|
||||||
|
|
||||||
|
**Priority:** High
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
Currently the app detects available updates via the Gitea API (`check_for_updates` command) but cannot apply them. Users must manually download and install the new version. On macOS and Linux this is a poor experience compared to Windows (where NSIS handles upgrades cleanly).
|
||||||
|
|
||||||
|
### Recommended approach: `tauri-plugin-updater`
|
||||||
|
|
||||||
|
Full in-app auto-update: detects, downloads, verifies, and applies updates seamlessly on all platforms. The user clicks "Update" and the app restarts with the new version.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. **Generate a Tauri update signing key pair** (this is Tauri's own Ed25519 key, not OS code signing):
|
||||||
|
```bash
|
||||||
|
npx @tauri-apps/cli signer generate -w ~/.tauri/triple-c.key
|
||||||
|
```
|
||||||
|
Set `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` in CI.
|
||||||
|
|
||||||
|
2. **Add `tauri-plugin-updater`** to Rust and JS dependencies.
|
||||||
|
|
||||||
|
3. **Create an update endpoint** that returns Tauri's expected JSON format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "v0.1.100",
|
||||||
|
"notes": "Changelog here",
|
||||||
|
"pub_date": "2026-03-01T00:00:00Z",
|
||||||
|
"platforms": {
|
||||||
|
"darwin-x86_64": { "signature": "...", "url": "https://..." },
|
||||||
|
"darwin-aarch64": { "signature": "...", "url": "https://..." },
|
||||||
|
"linux-x86_64": { "signature": "...", "url": "https://..." },
|
||||||
|
"windows-x86_64": { "signature": "...", "url": "https://..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This could be a static JSON file uploaded alongside release assets, or a small API that reads from Gitea releases and reformats.
|
||||||
|
|
||||||
|
4. **Configure the updater** in `tauri.conf.json`:
|
||||||
|
```json
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": ["https://repo.anhonesthost.net/...update-endpoint..."],
|
||||||
|
"pubkey": "<public key from step 1>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add frontend UI** for the update prompt (replace or enhance the existing update check flow).
|
||||||
|
|
||||||
|
6. **Update CI pipeline** to:
|
||||||
|
- Sign bundles with the Tauri key during build
|
||||||
|
- Upload `.sig` files alongside installers
|
||||||
|
- Generate/upload the update endpoint JSON
|
||||||
|
|
||||||
|
### References
|
||||||
|
- https://v2.tauri.app/plugin/updater/
|
||||||
|
- Existing update check code: `app/src-tauri/src/commands/update_commands.rs`
|
||||||
|
- Existing models: `app/src-tauri/src/models/update_info.rs`
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Triple-C (Claude-Code-Container)
|
|
||||||
|
|
||||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
|
||||||
- **Backend**: Rust (Tauri v2 framework)
|
|
||||||
- **Terminal**: xterm.js with WebGL rendering
|
|
||||||
- **Docker API**: bollard (pure Rust Docker client)
|
|
||||||
|
|
||||||
### Layout Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ TopBar (terminal tabs + Docker/Image status) │
|
|
||||||
├────────────┬────────────────────────────────────────┤
|
|
||||||
│ Sidebar │ Main Content (terminal views) │
|
|
||||||
│ (25% w, │ │
|
|
||||||
│ responsive│ │
|
|
||||||
│ min/max) │ │
|
|
||||||
├────────────┴────────────────────────────────────────┤
|
|
||||||
│ StatusBar (project/terminal counts) │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container Lifecycle
|
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
|
|
||||||
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
|
||||||
4. **Stop**: Container halted (filesystem persists in named volume)
|
|
||||||
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
|
||||||
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
|
||||||
|
|
||||||
### Mounts
|
|
||||||
|
|
||||||
| Target in Container | Source | Type | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/workspace` | Project directory | Bind | Read-write |
|
|
||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
|
||||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
|
|
||||||
|
|
||||||
### Authentication Modes
|
|
||||||
|
|
||||||
Each project can independently use one of:
|
|
||||||
|
|
||||||
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
|
||||||
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
|
||||||
|
|
||||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
|
||||||
|
|
||||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
|
||||||
|
|
||||||
### Docker Socket Path
|
|
||||||
|
|
||||||
The socket path is OS-aware:
|
|
||||||
- **Linux/macOS**: `/var/run/docker.sock`
|
|
||||||
- **Windows**: `//./pipe/docker_engine`
|
|
||||||
|
|
||||||
Users can override this in Settings via the global `docker_socket_path` option.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
|
|
||||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
|
|
||||||
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
|
||||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
|
||||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
|
||||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
|
||||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
|
||||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
|
||||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
|
||||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
|
||||||
|
|
||||||
## CSS / Styling Notes
|
|
||||||
|
|
||||||
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
|
|
||||||
- All colors use CSS custom properties defined in `index.css` `:root`
|
|
||||||
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
|
|
||||||
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
|
|
||||||
|
|
||||||
## Container Image
|
|
||||||
|
|
||||||
**Base**: Ubuntu 24.04
|
|
||||||
|
|
||||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
|
||||||
|
|
||||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Triple-C</title>
|
<title>Triple-C</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.18",
|
"@xterm/addon-webgl": "^0.18",
|
||||||
@@ -25,13 +42,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.7",
|
"typescript": "^5.7",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
||||||
|
process(inputs, outputs, parameters) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (input && input.length > 0 && input[0].length > 0) {
|
||||||
|
const samples = input[0]; // Float32Array, mono channel
|
||||||
|
const int16 = new Int16Array(samples.length);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
this.port.postMessage(int16.buffer, [int16.buffer]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||||
|
<title>Triple-C application icon, small-size variant</title>
|
||||||
|
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||||
|
survive being resampled down to 16 px. See build-icons.py. -->
|
||||||
|
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||||
|
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||||
|
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||||
|
stroke="#58A6FF" stroke-width="14"/>
|
||||||
|
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 812 B |
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -12,8 +12,7 @@ name = "triple-c"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -26,12 +25,34 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
include_dir = "0.7"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||||
|
iana-time-zone = "0.1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.9"
|
||||||
|
local-ip-address = "0.6"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
|
||||||
|
# container. Already in the tree transitively (reqwest), and the point of
|
||||||
|
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
# their backoff schedule under a paused clock instead of in real seconds.
|
||||||
|
tokio = { version = "1", features = ["full", "test-util"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
|
|||||||
@@ -1,3 +1,208 @@
|
|||||||
fn main() {
|
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
|
||||||
tauri_build::build()
|
//! to build unless every registered command is granted in exactly one capability file — the
|
||||||
|
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
|
||||||
|
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
|
||||||
|
//! could call any command.
|
||||||
|
//!
|
||||||
|
//! Because the census can only vouch for what it reads, the build also stops on any capability
|
||||||
|
//! tauri would load that the census does not: anything in `capabilities/` other than a
|
||||||
|
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
|
||||||
|
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
|
||||||
|
//!
|
||||||
|
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
|
||||||
|
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
|
||||||
|
|
||||||
|
#[path = "src/command_census.rs"]
|
||||||
|
mod command_census;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Stops the build. `what` names the check that failed, so a malformed capability file, a
|
||||||
|
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
|
||||||
|
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{what} ({} problem{}):",
|
||||||
|
problems.len(),
|
||||||
|
if problems.len() == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
for p in problems {
|
||||||
|
eprintln!(" - {p}");
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{hint}");
|
||||||
|
eprintln!();
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
|
||||||
|
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
|
||||||
|
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
|
||||||
|
src/command_census.rs can only vouch for what it reads.";
|
||||||
|
|
||||||
|
fn file_name(path: &Path) -> String {
|
||||||
|
path.file_name()
|
||||||
|
.expect("a directory entry has a file name")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
|
||||||
|
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
|
||||||
|
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/command_census.rs");
|
||||||
|
|
||||||
|
let lib_rs = std::fs::read_to_string("src/lib.rs")
|
||||||
|
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
|
||||||
|
let Some(commands) = command_census::registered_commands(&lib_rs) else {
|
||||||
|
fail(
|
||||||
|
"missing generate_handler! block",
|
||||||
|
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
|
||||||
|
.to_string()],
|
||||||
|
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
check_tauri_config();
|
||||||
|
let files = read_capabilities();
|
||||||
|
|
||||||
|
let problems = command_census::check(&commands, &files);
|
||||||
|
if !problems.is_empty() {
|
||||||
|
fail(
|
||||||
|
"capabilities do not match generate_handler!",
|
||||||
|
&problems,
|
||||||
|
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
|
||||||
|
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
|
||||||
|
capabilities/default.json. See src/command_census.rs.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_permissions(&commands);
|
||||||
|
|
||||||
|
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
|
||||||
|
// there is no owned form; leaking is fine in a process that exits right after.
|
||||||
|
let leaked: Vec<&'static str> = commands
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| &*Box::leak(c.into_boxed_str()))
|
||||||
|
.collect();
|
||||||
|
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
|
||||||
|
let attributes = tauri_build::Attributes::new()
|
||||||
|
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
|
||||||
|
if let Err(error) = tauri_build::try_build(attributes) {
|
||||||
|
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
|
||||||
|
println!("{error:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
|
||||||
|
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
|
||||||
|
/// capability could still reference (and the build would pass). Delete only the stale files:
|
||||||
|
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
|
||||||
|
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
|
||||||
|
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
|
||||||
|
/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
|
||||||
|
/// loads and which is skipped (see `command_census::is_os_junk`).
|
||||||
|
fn prune_permissions(commands: &[String]) {
|
||||||
|
let root = Path::new("permissions");
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("readable entry in permissions/").path();
|
||||||
|
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
|
||||||
|
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
|
||||||
|
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
|
||||||
|
let file = file.expect("readable entry").path();
|
||||||
|
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
let live = file.extension().is_some_and(|e| e == "toml")
|
||||||
|
&& commands.iter().any(|c| c == stem);
|
||||||
|
if !live {
|
||||||
|
std::fs::remove_file(&file)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fail(
|
||||||
|
"hand-written permission",
|
||||||
|
&[format!(
|
||||||
|
"{} is not generated by build.rs; hand-written permissions are not allowed \
|
||||||
|
(every grant is a bare allow-* string in a capability file)",
|
||||||
|
path.display()
|
||||||
|
)],
|
||||||
|
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
|
||||||
|
an app command is granted by listing allow-<command> in a capability file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every capability tauri will load, read the way the census reads it — or the build stops.
|
||||||
|
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
|
||||||
|
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
|
||||||
|
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut stray = Vec::new();
|
||||||
|
let mut invalid = Vec::new();
|
||||||
|
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
|
||||||
|
let path = entry.expect("readable entry in capabilities/").path();
|
||||||
|
let name = file_name(&path);
|
||||||
|
let is_file = path.is_file();
|
||||||
|
if is_file && command_census::is_os_junk(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
|
||||||
|
stray.push(problem);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
match command_census::capability_file(&name, &json) {
|
||||||
|
Ok(file) => files.push(file),
|
||||||
|
Err(problem) => invalid.push(problem),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stray.sort();
|
||||||
|
if !stray.is_empty() {
|
||||||
|
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
|
||||||
|
}
|
||||||
|
invalid.sort();
|
||||||
|
if !invalid.is_empty() {
|
||||||
|
fail("invalid capability file", &invalid, LAYOUT_HINT);
|
||||||
|
}
|
||||||
|
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
|
||||||
|
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
|
||||||
|
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
|
||||||
|
fn check_tauri_config() {
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
|
||||||
|
let path = entry.expect("readable entry in src-tauri/").path();
|
||||||
|
let name = file_name(&path);
|
||||||
|
match command_census::tauri_config_file(&name) {
|
||||||
|
None => {}
|
||||||
|
Some(false) => problems.push(format!(
|
||||||
|
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
|
||||||
|
declare capabilities it cannot see"
|
||||||
|
)),
|
||||||
|
Some(true) => {
|
||||||
|
let json =
|
||||||
|
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
problems.extend(command_census::tauri_config_problem(&name, &json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(json) = std::env::var("TAURI_CONFIG") {
|
||||||
|
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
|
||||||
|
}
|
||||||
|
problems.sort();
|
||||||
|
if !problems.is_empty() {
|
||||||
|
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"identifier": "file-viewer",
|
||||||
|
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
|
||||||
|
"windows": ["file-viewer-*"],
|
||||||
|
"permissions": [
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
|
"core:window:allow-destroy",
|
||||||
|
"core:webview:allow-internal-toggle-devtools",
|
||||||
|
"allow-viewer-get-state",
|
||||||
|
"allow-viewer-choose-file",
|
||||||
|
"allow-viewer-read-file",
|
||||||
|
"allow-viewer-poll-file",
|
||||||
|
"allow-viewer-write-file"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,868 @@
|
|||||||
|
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
|
||||||
|
//! container complete against the browser on the *host*.
|
||||||
|
//!
|
||||||
|
//! ## The problem
|
||||||
|
//!
|
||||||
|
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
|
||||||
|
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
|
||||||
|
//! then open a browser at a provider URL whose redirect points back to
|
||||||
|
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
|
||||||
|
//! is on the *container's* loopback, the browser is on the *host's*, and the
|
||||||
|
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
|
||||||
|
//! configurable, so nothing can be pre-published at container creation time.
|
||||||
|
//!
|
||||||
|
//! ## The mechanism
|
||||||
|
//!
|
||||||
|
//! While the bridge is enabled for a running project, poll the container every
|
||||||
|
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
|
||||||
|
//! that appears, bind the *same* port on the host's loopback and proxy each
|
||||||
|
//! accepted connection into the container over `docker exec … socat` (see
|
||||||
|
//! [`tunnel`]). When the in-container listener goes away, drop the host
|
||||||
|
//! listener. The host and container therefore agree on the port number, which is
|
||||||
|
//! the whole trick: the redirect URL the provider was given resolves correctly
|
||||||
|
//! on both sides.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle and teardown
|
||||||
|
//!
|
||||||
|
//! One poller task per project. It is the only thing that owns
|
||||||
|
//! [`PortForward`]s, and it always tears them down on its way out, so every way
|
||||||
|
//! the bridge can end funnels through the same code:
|
||||||
|
//!
|
||||||
|
//! | Trigger | Path |
|
||||||
|
//! |---|---|
|
||||||
|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||||
|
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||||
|
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||||
|
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||||
|
//!
|
||||||
|
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
|
||||||
|
//! released before it returns. As a backstop for any path that skips all of the
|
||||||
|
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
|
||||||
|
//! the accept loop, which drops the socket.
|
||||||
|
|
||||||
|
pub mod proc_net;
|
||||||
|
pub mod tunnel;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
|
use proc_net::PortFamily;
|
||||||
|
use tunnel::PortForward;
|
||||||
|
|
||||||
|
/// How often the container is polled for new/vanished loopback listeners.
|
||||||
|
/// Short enough that a login redirect isn't left waiting, cheap enough to run
|
||||||
|
/// continuously (one `cat` of two procfs files per tick).
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Emitted whenever the bridged-port set (or the conflict set) changes.
|
||||||
|
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
|
||||||
|
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC response models
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A port currently bound on the host loopback and forwarded into the container.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BridgedPort {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
|
pub bridged_at: String,
|
||||||
|
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||||
|
/// port still works, but not for a client that insists on `::1` — see
|
||||||
|
/// [`tunnel::PortForward::ipv6_warning`].
|
||||||
|
pub ipv6_warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PortConflict {
|
||||||
|
pub port: u16,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AuthBridgeStatus {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub active_ports: Vec<BridgedPort>,
|
||||||
|
pub conflicts: Vec<PortConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeStatus {
|
||||||
|
fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
active_ports: Vec::new(),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manager
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Everything the poller owns for one project. Live ports and conflicts sit
|
||||||
|
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||||
|
/// disturbing the poller.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BridgeState {
|
||||||
|
forwards: BTreeMap<u16, PortForward>,
|
||||||
|
conflicts: BTreeMap<u16, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeState {
|
||||||
|
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
active_ports: self
|
||||||
|
.forwards
|
||||||
|
.values()
|
||||||
|
.map(|f| BridgedPort {
|
||||||
|
port: f.port,
|
||||||
|
family: f.family,
|
||||||
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
ipv6_warning: f.ipv6_warning.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
conflicts: self
|
||||||
|
.conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|(port, reason)| PortConflict {
|
||||||
|
port: *port,
|
||||||
|
reason: reason.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectBridge {
|
||||||
|
/// Distinguishes this poller from a later one for the same project, so a
|
||||||
|
/// poller that exits late can't remove its replacement's map entry.
|
||||||
|
epoch: u64,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
poller: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AuthBridgeManager {
|
||||||
|
bridges: BridgeMap,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling for `project_id`. Idempotent: a call while a live poller
|
||||||
|
/// already exists for the project is a no-op.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) {
|
||||||
|
let mut map = self.bridges.lock().await;
|
||||||
|
|
||||||
|
// A finished poller has already torn its ports down, so its entry is
|
||||||
|
// just a husk and can be replaced. A live one means we're already on.
|
||||||
|
if map
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|b| !b.poller.is_finished())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: starting for project {} (container {})",
|
||||||
|
project_id,
|
||||||
|
&container_id[..container_id.len().min(12)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let poller = tokio::spawn(poll_loop(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
epoch,
|
||||||
|
app,
|
||||||
|
store,
|
||||||
|
state.clone(),
|
||||||
|
self.bridges.clone(),
|
||||||
|
cancel_rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
project_id,
|
||||||
|
ProjectBridge {
|
||||||
|
epoch,
|
||||||
|
cancel: cancel_tx,
|
||||||
|
state,
|
||||||
|
poller,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the bridge for one project and wait until every host port it held
|
||||||
|
/// has been released.
|
||||||
|
pub async fn stop(&self, project_id: &str) {
|
||||||
|
// Remove under the lock, then release it before awaiting: the poller
|
||||||
|
// takes the same lock to deregister itself on exit.
|
||||||
|
let bridge = self.bridges.lock().await.remove(project_id);
|
||||||
|
if let Some(bridge) = bridge {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every bridge. Used on app exit.
|
||||||
|
pub async fn stop_all(&self) {
|
||||||
|
let bridges: Vec<(String, ProjectBridge)> =
|
||||||
|
self.bridges.lock().await.drain().collect();
|
||||||
|
for (project_id, bridge) in bridges {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status. `enabled` comes from the persisted project record, so a
|
||||||
|
/// project whose bridge is on but whose container is stopped still reports
|
||||||
|
/// `enabled: true` with no active ports.
|
||||||
|
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
// Clone the per-project handle out and drop the map lock before taking
|
||||||
|
// the state lock. Holding both across the nested await is not a
|
||||||
|
// deadlock — the order is consistently bridges→state — but it puts a
|
||||||
|
// cheap UI status call behind whatever the poller is doing under
|
||||||
|
// `state`, and behind every other project's status call too.
|
||||||
|
let state = self.bridges.lock().await.get(project_id).map(|b| b.state.clone());
|
||||||
|
match state {
|
||||||
|
Some(state) => state.lock().await.snapshot(enabled),
|
||||||
|
None => AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
..AuthBridgeStatus::disabled()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Poller
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn poll_loop(
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
epoch: u64,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
bridges: BridgeMap,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
let mut exec_failures: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Stop conditions checked every tick, so the bridge winds itself down
|
||||||
|
// even when nothing calls `stop()` (container died, project deleted
|
||||||
|
// out from under us, flag flipped off by another path).
|
||||||
|
let project = match store.get(&project_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !project.auth_bridge_enabled {
|
||||||
|
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One exec per tick reads both procfs files.
|
||||||
|
//
|
||||||
|
// Absolute path, deliberately: the image's `ENV PATH` puts a
|
||||||
|
// container-writable directory first, so a bare `cat` is a name the
|
||||||
|
// container can rebind to a shim that prints whatever it likes. It
|
||||||
|
// still could not make us bind a *non-loopback* port, but it decides
|
||||||
|
// how much output this loop ingests and how many host ports it is asked
|
||||||
|
// for, which is why the call is also length-capped and the result
|
||||||
|
// count is capped in `reconcile`.
|
||||||
|
let cmd = vec![
|
||||||
|
"/usr/bin/cat".to_string(),
|
||||||
|
"/proc/net/tcp".to_string(),
|
||||||
|
"/proc/net/tcp6".to_string(),
|
||||||
|
];
|
||||||
|
// Cancellation races the exec, not just the sleep, so disabling the
|
||||||
|
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||||
|
let discovery = tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||||
|
};
|
||||||
|
|
||||||
|
match discovery {
|
||||||
|
Ok(text) => {
|
||||||
|
exec_failures = 0;
|
||||||
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||||
|
// Re-read every tick: a project can gain a port mapping and the
|
||||||
|
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||||
|
// bridge is running, and a stale reservation set is a hole.
|
||||||
|
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||||
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
|
emit_status(&app, &project_id, &state, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
exec_failures += 1;
|
||||||
|
// Transient failures happen (container restarting, engine busy);
|
||||||
|
// only complain once per streak.
|
||||||
|
if exec_failures == 1 {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
_ = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown(&project_id, &state).await;
|
||||||
|
emit_status(
|
||||||
|
&app,
|
||||||
|
&project_id,
|
||||||
|
&state,
|
||||||
|
store
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|p| p.auth_bridge_enabled),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Deregister, unless a newer poller has already taken this project's slot.
|
||||||
|
let mut map = bridges.lock().await;
|
||||||
|
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
|
||||||
|
map.remove(&project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every port this project's bridge must not take.
|
||||||
|
///
|
||||||
|
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||||
|
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||||
|
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||||
|
/// enumerated here. Four sources:
|
||||||
|
///
|
||||||
|
/// 1. **This project's own published ports** — a container port that Docker
|
||||||
|
/// already publishes has a host-side path, and the mapping's host port is a
|
||||||
|
/// binding we must not fight over.
|
||||||
|
/// 2. **Every other project's published host ports.** The container names the
|
||||||
|
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||||
|
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||||
|
/// the host end of another project's mapping is reserved: its container end
|
||||||
|
/// is a number inside a different network namespace and means nothing here.
|
||||||
|
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||||
|
/// and the web terminal. All three are off by default and bind on demand, so
|
||||||
|
/// first-come would win: a container that binds container-loopback 4000
|
||||||
|
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||||
|
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||||
|
/// anything on the host dialling 4000 — including *other project
|
||||||
|
/// containers*, which reach the gateway by host address — is talking to the
|
||||||
|
/// squatting container instead. The web terminal is the worst of the three,
|
||||||
|
/// because its access token travels in the URL query. Both the *configured*
|
||||||
|
/// port and the shipped default are reserved: the configured one is what the
|
||||||
|
/// service will bind next, and the default is what it falls back to for a
|
||||||
|
/// fresh profile or a settings file that failed to parse.
|
||||||
|
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||||
|
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||||
|
/// terms.
|
||||||
|
///
|
||||||
|
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||||
|
/// policy is unit-testable without a store, a container or an app handle.
|
||||||
|
fn skipped_ports(
|
||||||
|
project: &crate::models::Project,
|
||||||
|
all_projects: &[crate::models::Project],
|
||||||
|
settings: &crate::models::AppSettings,
|
||||||
|
) -> HashSet<u16> {
|
||||||
|
let mut skip: HashSet<u16> = project
|
||||||
|
.port_mappings
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Other projects: host end only.
|
||||||
|
skip.extend(
|
||||||
|
all_projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.id != project.id)
|
||||||
|
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||||
|
);
|
||||||
|
|
||||||
|
skip.extend(app_service_host_ports(settings));
|
||||||
|
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||||
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||||
|
skip
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current app settings, or defaults if the state is not reachable.
|
||||||
|
///
|
||||||
|
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||||
|
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||||
|
/// worse than one that falls back to the shipped port numbers — which are what
|
||||||
|
/// the services use anyway until someone changes them.
|
||||||
|
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||||
|
use tauri::Manager;
|
||||||
|
app.try_state::<crate::AppState>()
|
||||||
|
.map(|state| state.settings_store.get())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host ports this app's own sibling services bind, configured value and
|
||||||
|
/// shipped default alike.
|
||||||
|
///
|
||||||
|
/// Read off the settings models rather than restated as literals here: a
|
||||||
|
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||||
|
/// and the failure mode of drift is a reservation that no longer covers the
|
||||||
|
/// service it was written for.
|
||||||
|
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||||
|
use crate::models::{SttSettings, WebTerminalSettings};
|
||||||
|
|
||||||
|
vec![
|
||||||
|
// LiteLLM gateway (`docker/gateway.rs`).
|
||||||
|
settings.gateway.port,
|
||||||
|
crate::models::default_gateway_port(),
|
||||||
|
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||||
|
settings.stt.port,
|
||||||
|
SttSettings::default().port,
|
||||||
|
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||||
|
// its access token is in the URL query.
|
||||||
|
settings.web_terminal.port,
|
||||||
|
WebTerminalSettings::default().port,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Reservations
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Container loopback ports another feature owns, which the bridge must leave
|
||||||
|
/// alone.
|
||||||
|
///
|
||||||
|
/// The bridge's contract is "mirror every container loopback listener onto the
|
||||||
|
/// same host port, **unauthenticated**" — correct for the throwaway OAuth
|
||||||
|
/// callback listeners it exists for, wrong for anything sensitive. The
|
||||||
|
/// browser-view pane runs Playwright's dashboard on a container loopback port
|
||||||
|
/// in this range and puts a token-gated listener in front of it; mirroring that
|
||||||
|
/// port here would quietly publish an ungated second door to full control of a
|
||||||
|
/// browser inside the container.
|
||||||
|
///
|
||||||
|
/// This is a constant rather than a registry the pane populates at runtime, and
|
||||||
|
/// that is the point: Playwright's dashboard is a detached daemon that outlives
|
||||||
|
/// the app, so after a crash an orphaned viewer can still be listening with
|
||||||
|
/// nothing in this process left to remember it. A static range is the only form
|
||||||
|
/// of the rule that survives a restart. It must stay in step with
|
||||||
|
/// `browser_view::VIEWER_PORTS`, which asserts on it.
|
||||||
|
pub const RESERVED_CONTAINER_PORTS: std::ops::RangeInclusive<u16> = 39321..=39328;
|
||||||
|
|
||||||
|
/// Host ports another feature binds on demand, which the bridge must not take
|
||||||
|
/// first.
|
||||||
|
///
|
||||||
|
/// These are the browser-view proxy's host ports. The bridge binds *host* ports
|
||||||
|
/// named by the container, so a container listening on 47820 would have the
|
||||||
|
/// bridge take the host side of that number — and then the browser-view pane,
|
||||||
|
/// which only binds when the user opens it, finds its port gone. The two ranges
|
||||||
|
/// are separate constants because they guard opposite ends of the same
|
||||||
|
/// mechanism: [`RESERVED_CONTAINER_PORTS`] is about not *publishing* something,
|
||||||
|
/// this one is about not *stealing* something.
|
||||||
|
pub const RESERVED_HOST_PORTS: std::ops::RangeInclusive<u16> =
|
||||||
|
crate::browser_view::proxy::PROXY_PORTS;
|
||||||
|
|
||||||
|
/// Most host ports the bridge will hold for one project at a time.
|
||||||
|
///
|
||||||
|
/// The discovery input is entirely container-controlled, and each
|
||||||
|
/// [`PortForward`] costs two listeners plus a task, so without a cap a
|
||||||
|
/// container that reports tens of thousands of fake listeners exhausts the
|
||||||
|
/// app's file descriptors and the host's ephemeral ports in a single tick. A
|
||||||
|
/// real login flow uses one or two ports at a time; anything past a couple of
|
||||||
|
/// dozen is not a login.
|
||||||
|
const MAX_FORWARDS: usize = 24;
|
||||||
|
|
||||||
|
/// Most conflicts recorded at once, so a flood of unbindable ports can't grow
|
||||||
|
/// the status payload (and the UI list) without bound either.
|
||||||
|
const MAX_CONFLICTS: usize = 32;
|
||||||
|
|
||||||
|
/// Bring the set of host listeners in line with what the container is currently
|
||||||
|
/// listening on. Returns whether anything the UI cares about changed.
|
||||||
|
async fn reconcile(
|
||||||
|
container_id: &str,
|
||||||
|
discovered: &BTreeMap<u16, PortFamily>,
|
||||||
|
skip: &HashSet<u16>,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
|
// Drop host listeners whose container-side counterpart vanished, became
|
||||||
|
// covered by an explicit port mapping, or changed address family (a family
|
||||||
|
// change alters the socat target, so it has to be rebound below).
|
||||||
|
let stale: Vec<u16> = st
|
||||||
|
.forwards
|
||||||
|
.iter()
|
||||||
|
.filter(|(port, forward)| match discovered.get(port) {
|
||||||
|
None => true,
|
||||||
|
Some(_) if skip.contains(port) => true,
|
||||||
|
Some(family) => *family != forward.family,
|
||||||
|
})
|
||||||
|
.map(|(port, _)| *port)
|
||||||
|
.collect();
|
||||||
|
for port in stale {
|
||||||
|
if let Some(mut forward) = st.forwards.remove(&port) {
|
||||||
|
forward.shutdown().await;
|
||||||
|
log::info!("Auth bridge: released host port {}", port);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget conflicts for ports that are no longer relevant.
|
||||||
|
let before = st.conflicts.len();
|
||||||
|
st.conflicts
|
||||||
|
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
|
||||||
|
changed |= st.conflicts.len() != before;
|
||||||
|
|
||||||
|
for (&port, &family) in discovered {
|
||||||
|
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if st.forwards.len() >= MAX_FORWARDS {
|
||||||
|
// Don't even attempt the bind: the point of the cap is to stop the
|
||||||
|
// container dictating how many host resources we take.
|
||||||
|
changed |= note_conflict(
|
||||||
|
&mut st,
|
||||||
|
port,
|
||||||
|
format!(
|
||||||
|
"The auth bridge is already holding {} ports for this project; \
|
||||||
|
{} was not bridged.",
|
||||||
|
MAX_FORWARDS, port
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||||
|
Ok(forward) => {
|
||||||
|
if st.conflicts.remove(&port).is_some() {
|
||||||
|
log::info!("Auth bridge: host port {} became available", port);
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
|
||||||
|
port,
|
||||||
|
family.socat_target(port),
|
||||||
|
family
|
||||||
|
);
|
||||||
|
st.forwards.insert(port, forward);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Conflict policy: never fight for a port. Something else on the
|
||||||
|
// host owns it — another project's bridge, or an unrelated
|
||||||
|
// process. Skip it, record why so the UI can say so, and retry
|
||||||
|
// on later ticks in case the owner releases it. Warn only on
|
||||||
|
// the transition so a long-lived conflict doesn't spam the log.
|
||||||
|
let reason = format!(
|
||||||
|
"Host port {} is already in use ({}); not bridged.",
|
||||||
|
port, e
|
||||||
|
);
|
||||||
|
if st.conflicts.get(&port) != Some(&reason) {
|
||||||
|
log::warn!("Auth bridge: {}", reason);
|
||||||
|
}
|
||||||
|
changed |= note_conflict(&mut st, port, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record why a port wasn't bridged, up to [`MAX_CONFLICTS`]. Returns whether
|
||||||
|
/// the recorded set changed.
|
||||||
|
fn note_conflict(state: &mut BridgeState, port: u16, reason: String) -> bool {
|
||||||
|
match state.conflicts.get(&port) {
|
||||||
|
Some(existing) if *existing == reason => false,
|
||||||
|
Some(_) => {
|
||||||
|
state.conflicts.insert(port, reason);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None if state.conflicts.len() < MAX_CONFLICTS => {
|
||||||
|
state.conflicts.insert(port, reason);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||||
|
/// return nothing is bound.
|
||||||
|
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
let forwards = std::mem::take(&mut st.forwards);
|
||||||
|
st.conflicts.clear();
|
||||||
|
let count = forwards.len();
|
||||||
|
for (_, mut forward) in forwards {
|
||||||
|
forward.shutdown().await;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: released {} host port(s) for project {}",
|
||||||
|
count,
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_status(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
let status = state.lock().await.snapshot(enabled);
|
||||||
|
let _ = app.emit(
|
||||||
|
AUTH_BRIDGE_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"status": status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
|
let mut p = Project::new(
|
||||||
|
"test".to_string(),
|
||||||
|
vec![ProjectPath {
|
||||||
|
host_path: "/tmp".to_string(),
|
||||||
|
mount_name: "tmp".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
p.port_mappings = mappings
|
||||||
|
.into_iter()
|
||||||
|
.map(|(host_port, container_port)| PortMapping {
|
||||||
|
host_port,
|
||||||
|
container_port,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The common case: one project, no siblings, stock settings.
|
||||||
|
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||||
|
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ports_already_published_by_docker_are_skipped() {
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
|
// is already reachable, and the host port is Docker's binding.
|
||||||
|
assert!(skip.contains(&8080));
|
||||||
|
assert!(skip.contains(&8081));
|
||||||
|
assert!(!skip.contains(&34567));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
let project = project_with_mappings(vec![]);
|
||||||
|
let skip = skip_for(&project);
|
||||||
|
|
||||||
|
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||||
|
expected.extend(RESERVED_HOST_PORTS);
|
||||||
|
expected.extend(app_service_host_ports(&settings));
|
||||||
|
assert_eq!(skip, expected);
|
||||||
|
|
||||||
|
// The ranges and the service ports are disjoint, so nothing above is
|
||||||
|
// accidentally counting the same port twice.
|
||||||
|
assert_eq!(
|
||||||
|
skip.len(),
|
||||||
|
RESERVED_CONTAINER_PORTS.clone().count()
|
||||||
|
+ RESERVED_HOST_PORTS.clone().count()
|
||||||
|
+ 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_apps_own_host_services_are_never_taken() {
|
||||||
|
// The bug this guards: the reserved set used to cover only the
|
||||||
|
// browser-view ranges and this project's own mappings, so a container
|
||||||
|
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||||
|
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||||
|
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||||
|
// door before they could bind it.
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
|
|
||||||
|
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||||
|
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||||
|
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||||
|
|
||||||
|
// The shipped defaults, spelled out once so a change to any of them is
|
||||||
|
// a change to this assertion and not a silent narrowing.
|
||||||
|
assert!(skip.contains(&4000));
|
||||||
|
assert!(skip.contains(&9876));
|
||||||
|
assert!(skip.contains(&7681));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.gateway.port = 4321;
|
||||||
|
settings.stt.port = 9000;
|
||||||
|
settings.web_terminal.port = 8443;
|
||||||
|
let project = project_with_mappings(vec![]);
|
||||||
|
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||||
|
|
||||||
|
for port in [4321, 9000, 8443] {
|
||||||
|
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
// The default stays reserved too: it is what the service falls back to
|
||||||
|
// for a fresh profile or an unparseable settings file, so leaving it
|
||||||
|
// open is leaving the same squat available one restart later.
|
||||||
|
for port in [4000, 9876, 7681] {
|
||||||
|
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_projects_published_host_port_is_not_stolen() {
|
||||||
|
// The container names the *host* port. Without this, project A's
|
||||||
|
// container listening on 8080 takes the host 8080 that project B
|
||||||
|
// publishes on — the bridge wins the race whenever B's container is not
|
||||||
|
// running yet.
|
||||||
|
let mine = project_with_mappings(vec![]);
|
||||||
|
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||||
|
theirs.id = format!("{}-other", mine.id);
|
||||||
|
|
||||||
|
let skip = skipped_ports(
|
||||||
|
&mine,
|
||||||
|
&[mine.clone(), theirs.clone()],
|
||||||
|
&AppSettings::default(),
|
||||||
|
);
|
||||||
|
assert!(skip.contains(&8080), "another project's host port");
|
||||||
|
// …but not the other project's *container* port: that number lives in a
|
||||||
|
// different network namespace and means nothing on this host, and
|
||||||
|
// reserving it would refuse a legitimate login callback for no reason.
|
||||||
|
assert!(!skip.contains(&3000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_browser_views_host_ports_are_never_taken() {
|
||||||
|
// The bridge binds *host* ports chosen by the container, so without
|
||||||
|
// this it can take the port the browser-view proxy will want later —
|
||||||
|
// that pane binds on demand, so first-come would win.
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
|
for port in RESERVED_HOST_PORTS {
|
||||||
|
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
assert!(!skip.contains(&(RESERVED_HOST_PORTS.end() + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflicts_stop_being_recorded_past_the_cap() {
|
||||||
|
let mut st = BridgeState::default();
|
||||||
|
for port in 1000u16..1000 + MAX_CONFLICTS as u16 {
|
||||||
|
assert!(note_conflict(&mut st, port, "busy".to_string()));
|
||||||
|
}
|
||||||
|
// Past the cap: new ports are dropped rather than growing the status
|
||||||
|
// payload the UI renders.
|
||||||
|
assert!(!note_conflict(&mut st, 9999, "busy".to_string()));
|
||||||
|
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
||||||
|
// A changed reason for a port already tracked still updates.
|
||||||
|
assert!(!note_conflict(&mut st, 1000, "busy".to_string()));
|
||||||
|
assert!(note_conflict(&mut st, 1000, "different".to_string()));
|
||||||
|
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_host_ports_one_container_can_demand_are_capped() {
|
||||||
|
// The container fully controls the discovery input (it can shim the
|
||||||
|
// probe command), and each forward costs two listeners plus a task —
|
||||||
|
// uncapped, one tick could exhaust the app's fds and the host's
|
||||||
|
// ephemeral ports.
|
||||||
|
let discovered: BTreeMap<u16, PortFamily> =
|
||||||
|
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||||
|
|
||||||
|
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
||||||
|
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
assert!(
|
||||||
|
st.forwards.len() <= MAX_FORWARDS,
|
||||||
|
"bridged {} ports, cap is {}",
|
||||||
|
st.forwards.len(),
|
||||||
|
MAX_FORWARDS
|
||||||
|
);
|
||||||
|
assert!(st.conflicts.len() <= MAX_CONFLICTS);
|
||||||
|
// Nowhere near the 200 the "container" asked for.
|
||||||
|
assert!(st.forwards.len() + st.conflicts.len() < discovered.len());
|
||||||
|
|
||||||
|
for (_, mut forward) in std::mem::take(&mut st.forwards) {
|
||||||
|
forward.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_browser_views_ports_are_never_mirrored() {
|
||||||
|
// Mirroring these would publish an ungated second door to the
|
||||||
|
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||||
|
// token-checking listener.
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
|
for port in RESERVED_CONTAINER_PORTS {
|
||||||
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||||
|
|
||||||
|
// Reservations coexist with Docker's own published ports.
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||||
|
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
|
||||||
|
//! `/proc/net/tcp6` from inside the container.
|
||||||
|
//!
|
||||||
|
//! ## Why /proc and not `ss`
|
||||||
|
//!
|
||||||
|
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
|
||||||
|
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
|
||||||
|
//! and needs no package at all, so discovery works in the stock image and in any
|
||||||
|
//! snapshot derived from it.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! Both files are fixed-column text with a header line:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
|
||||||
|
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
|
||||||
|
//!
|
||||||
|
//! ## Hex and endianness
|
||||||
|
//!
|
||||||
|
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
|
||||||
|
//! the same way:
|
||||||
|
//!
|
||||||
|
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
|
||||||
|
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
|
||||||
|
//! order*, which is little-endian on every platform this app targets. So each
|
||||||
|
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
|
||||||
|
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
|
||||||
|
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
|
||||||
|
//!
|
||||||
|
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
|
||||||
|
//! digits), each converted independently, in order, to fill the 16 address
|
||||||
|
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
|
||||||
|
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
|
||||||
|
//!
|
||||||
|
//! ## What counts as loopback
|
||||||
|
//!
|
||||||
|
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
|
||||||
|
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
|
||||||
|
//! outside world — that is the port-mappings feature's job, not the auth
|
||||||
|
//! bridge's — so those rows are dropped.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The `st` column value for `TCP_LISTEN`.
|
||||||
|
const TCP_LISTEN: &str = "0A";
|
||||||
|
|
||||||
|
/// Which loopback address family (or families) a container-side listener was
|
||||||
|
/// found on. Determines the `socat` target address used to reach it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PortFamily {
|
||||||
|
/// Only `127.0.0.0/8`.
|
||||||
|
V4,
|
||||||
|
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
|
||||||
|
/// on Linux, so `claude login` frequently binds `::1` and nothing else
|
||||||
|
/// (anthropics/claude-code#44844).
|
||||||
|
V6,
|
||||||
|
/// Both — reachable either way; we use IPv4.
|
||||||
|
Dual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortFamily {
|
||||||
|
fn merge(self, other: PortFamily) -> PortFamily {
|
||||||
|
if self == other {
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
PortFamily::Dual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `socat` address that reaches this listener from inside the container.
|
||||||
|
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
|
||||||
|
/// (verified: connect gets ECONNREFUSED), hence the split.
|
||||||
|
pub fn socat_target(&self, port: u16) -> String {
|
||||||
|
match self {
|
||||||
|
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
|
||||||
|
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed LISTEN row that survived the loopback filter.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct LoopbackListener {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
|
||||||
|
/// the set of loopback ports being listened on, keyed by port with the families
|
||||||
|
/// merged (a port bound on both `127.0.0.1` and `::1` yields
|
||||||
|
/// [`PortFamily::Dual`]).
|
||||||
|
///
|
||||||
|
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
|
||||||
|
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
|
||||||
|
/// combined output — are silently ignored rather than failing the whole poll.
|
||||||
|
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
|
||||||
|
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
|
||||||
|
for listener in parse_listener_rows(text) {
|
||||||
|
ports
|
||||||
|
.entry(listener.port)
|
||||||
|
.and_modify(|f| *f = f.merge(listener.family))
|
||||||
|
.or_insert(listener.family);
|
||||||
|
}
|
||||||
|
ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row-level parse, before per-port family merging. Split out so tests can
|
||||||
|
/// assert on the individual rows.
|
||||||
|
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
|
||||||
|
text.lines().filter_map(parse_listener_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let _sl = fields.next()?;
|
||||||
|
let local_address = fields.next()?;
|
||||||
|
let _rem_address = fields.next()?;
|
||||||
|
let state = fields.next()?;
|
||||||
|
|
||||||
|
if state != TCP_LISTEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (addr_hex, port_hex) = local_address.split_once(':')?;
|
||||||
|
// The port is a straightforward big-endian hex u16 — no byte swapping.
|
||||||
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||||
|
if port == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match addr_hex.len() {
|
||||||
|
8 => {
|
||||||
|
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
|
||||||
|
addr.is_loopback().then_some(PortFamily::V4)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
|
||||||
|
let group = std::str::from_utf8(group).ok()?;
|
||||||
|
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
|
||||||
|
}
|
||||||
|
let addr = Ipv6Addr::from(octets);
|
||||||
|
// An IPv4-mapped row describes a v4 socket, so it is reachable at
|
||||||
|
// 127.0.0.1 and must be classified as v4, not v6.
|
||||||
|
match addr.to_ipv4_mapped() {
|
||||||
|
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
|
||||||
|
None => addr.is_loopback().then_some(PortFamily::V6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(LoopbackListener { port, family })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `%08X` procfs address word into its four address bytes in network
|
||||||
|
/// order. The kernel prints the word in host byte order, so the recovered bytes
|
||||||
|
/// are the little-endian expansion of the parsed integer.
|
||||||
|
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
|
||||||
|
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
|
||||||
|
/// with three listeners deliberately started:
|
||||||
|
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
|
||||||
|
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
|
||||||
|
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
|
||||||
|
const REAL_PROC_NET_TCP: &str = concat!(
|
||||||
|
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
|
||||||
|
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
|
||||||
|
/// the Node listener bound to `::1` only — the case that motivates the
|
||||||
|
/// TCP6 socat target.
|
||||||
|
const REAL_PROC_NET_TCP6: &str = concat!(
|
||||||
|
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||||
|
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
fn both_files() -> String {
|
||||||
|
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv4_loopback_row_with_little_endian_address() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
|
||||||
|
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 0x8707,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(rows[0].port, 34567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv6_loopback_row() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34568,
|
||||||
|
family: PortFamily::V6
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_wildcard_bind_addresses() {
|
||||||
|
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
|
||||||
|
// the port-mappings feature's territory.
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert!(!ports.contains_key(&34569));
|
||||||
|
|
||||||
|
// Same for the IPv6 wildcard and a non-loopback unicast address.
|
||||||
|
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(wildcard_v6).is_empty());
|
||||||
|
assert!(parse_listener_rows(lan_v4).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_both_files_concatenated_as_one_exec_output() {
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert_eq!(ports.len(), 2);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
|
||||||
|
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_families_for_a_dual_stack_port() {
|
||||||
|
let dual = format!(
|
||||||
|
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
|
||||||
|
both_files()
|
||||||
|
);
|
||||||
|
let ports = parse_loopback_listeners(&dual);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_mapped_loopback_is_reported_as_v4() {
|
||||||
|
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
|
||||||
|
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert_eq!(
|
||||||
|
parse_listener_rows(row),
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34567,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_listen_states() {
|
||||||
|
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
|
||||||
|
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(established).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_headers_and_garbage() {
|
||||||
|
assert!(parse_listener_rows("").is_empty());
|
||||||
|
assert!(parse_listener_rows(
|
||||||
|
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
// Truncated / malformed rows must not panic or be accepted.
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socat_target_matches_family() {
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::V4.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::Dual.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
//! Host-side loopback listener for one bridged port, and the per-connection
|
||||||
|
//! tunnel that carries its bytes into the container.
|
||||||
|
//!
|
||||||
|
//! ## Why not connect to the container's IP
|
||||||
|
//!
|
||||||
|
//! Container IPs are not routable from the host on Docker Desktop (macOS and
|
||||||
|
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
|
||||||
|
//! transport. The Docker API is the only channel guaranteed to reach the
|
||||||
|
//! container from the host, so each accepted connection is carried by a
|
||||||
|
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
|
||||||
|
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||||
|
//!
|
||||||
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
|
//! interactive terminal sessions are built on.
|
||||||
|
//!
|
||||||
|
//! ## What the host listener is, and is not
|
||||||
|
//!
|
||||||
|
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||||
|
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||||
|
//! nothing in that chain can be taught to present a token — so there is no path
|
||||||
|
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||||
|
//! the container-side listener. That includes **any web page the user has open**,
|
||||||
|
//! which can port-scan loopback from script.
|
||||||
|
//!
|
||||||
|
//! Two things narrow that, and neither is a substitute for the other:
|
||||||
|
//!
|
||||||
|
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||||
|
//! ports while its container is running.
|
||||||
|
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||||
|
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||||
|
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||||
|
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||||
|
//! what an OAuth redirect is.
|
||||||
|
//!
|
||||||
|
//! The residual risk, stated plainly rather than papered over: a client that
|
||||||
|
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||||
|
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||||
|
//! probe must all still work) but also any browser predating fetch metadata
|
||||||
|
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||||
|
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||||
|
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||||
|
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||||
|
//! port: if the container has something more interesting than a throwaway OAuth
|
||||||
|
//! listener on loopback, a same-machine caller reaches it.
|
||||||
|
//!
|
||||||
|
//! ## Bounds
|
||||||
|
//!
|
||||||
|
//! Every accepted connection costs a `docker exec`, and the number of
|
||||||
|
//! connections is decided by whoever can reach the port. So each forward caps
|
||||||
|
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||||
|
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||||
|
//! exec is created), and drops a connection the container has gone quiet on
|
||||||
|
//! ([`IDLE_TIMEOUT`]).
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||||
|
|
||||||
|
use super::proc_net::PortFamily;
|
||||||
|
|
||||||
|
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||||
|
/// only needs to not be pathological.
|
||||||
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Concurrent connections one forwarded port will carry.
|
||||||
|
///
|
||||||
|
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||||
|
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||||
|
/// connection, occasionally a handful; this is generous for that and still a
|
||||||
|
/// bound the engine will not notice.
|
||||||
|
const MAX_CONNECTIONS: usize = 16;
|
||||||
|
|
||||||
|
/// How long an accepted connection has to send its first byte before it is
|
||||||
|
/// dropped, *without* a `docker exec` ever being created for it.
|
||||||
|
///
|
||||||
|
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||||
|
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||||
|
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||||
|
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||||
|
/// that is the trade, and it is the only protocol shape affected.
|
||||||
|
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long a live connection may go with nothing coming back from the container
|
||||||
|
/// before it is torn down. Generous, because a bridged port is not always a
|
||||||
|
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||||
|
/// exec forever.
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||||
|
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||||
|
const MAX_HEAD: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// How long the rest of a request head has, once the first line has identified
|
||||||
|
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||||
|
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
|
/// child running.
|
||||||
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
|
|
||||||
|
impl Drop for AbortOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One host loopback port bound and proxied into the container.
|
||||||
|
///
|
||||||
|
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
|
||||||
|
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
|
||||||
|
/// releases the port *and* tears down every connection under it. [`Drop`] does
|
||||||
|
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
|
||||||
|
/// also awaiting the aborted task, which guarantees the socket is closed before
|
||||||
|
/// the caller proceeds (important when a port is rebound right after).
|
||||||
|
pub struct PortForward {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
pub bridged_at: String,
|
||||||
|
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||||
|
///
|
||||||
|
/// A half-bound forward is the one failure mode that looks like a success:
|
||||||
|
/// the status says the port is bridged, and a browser that resolves
|
||||||
|
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||||
|
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||||
|
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||||
|
/// so, rather than being logged at debug where nobody sees it.
|
||||||
|
pub ipv6_warning: Option<String>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForward {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortForward {
|
||||||
|
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||||
|
///
|
||||||
|
/// The bind happens before the task is spawned, so an already-taken port is
|
||||||
|
/// reported to the caller as an error rather than disappearing into a
|
||||||
|
/// background task.
|
||||||
|
pub async fn bind(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
family: PortFamily,
|
||||||
|
) -> Result<Self, std::io::Error> {
|
||||||
|
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
|
||||||
|
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
|
||||||
|
// an unauthenticated service inside the container that deliberately
|
||||||
|
// bound loopback because it expected to be reachable from nowhere else.
|
||||||
|
// Binding a wildcard address here would publish container internals to
|
||||||
|
// every host on the LAN. Do not "fix" a connectivity problem by
|
||||||
|
// widening these addresses.
|
||||||
|
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
|
||||||
|
|
||||||
|
// Also take ::1 when it is available. Browsers and CLIs resolve
|
||||||
|
// `localhost` to either family, and the IPv6 answer is often tried
|
||||||
|
// first, so a v4-only host listener would miss those callbacks. This is
|
||||||
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||||
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||||
|
let (v6, ipv6_warning) =
|
||||||
|
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => (Some(l), None),
|
||||||
|
Err(e) => {
|
||||||
|
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||||
|
// not about whether to say anything: on a host where
|
||||||
|
// `localhost` resolves to `::1` and the client does not
|
||||||
|
// fall back to IPv4, the callback is refused while the
|
||||||
|
// bridge reports itself healthy — a silent failure with no
|
||||||
|
// thread back to this line.
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
|
||||||
|
a client that resolves localhost to ::1 without falling back will not reach it",
|
||||||
|
port,
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||||
|
localhost to ::1 without falling back will not reach this port.",
|
||||||
|
port, e
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = family.socat_target(port);
|
||||||
|
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
family,
|
||||||
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
ipv6_warning,
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting, drop the host socket, and abort every in-flight
|
||||||
|
/// connection. Awaits the aborted task so the port is provably released
|
||||||
|
/// when this returns.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||||
|
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||||
|
async fn accept_loop(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
target: String,
|
||||||
|
v4: TcpListener,
|
||||||
|
v6: Option<TcpListener>,
|
||||||
|
) {
|
||||||
|
let mut conns: JoinSet<()> = JoinSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
r = v4.accept() => r,
|
||||||
|
r = accept_optional(v6.as_ref()) => r,
|
||||||
|
// Reap finished tunnels so the JoinSet doesn't grow without bound.
|
||||||
|
// When the set is empty `join_next()` yields None, the pattern fails
|
||||||
|
// to match, and the branch simply drops out of the select.
|
||||||
|
Some(_) = conns.join_next() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, peer)) => {
|
||||||
|
// Reap first, so the cap counts *live* connections rather than
|
||||||
|
// every one this listener has ever accepted.
|
||||||
|
while conns.try_join_next().is_some() {}
|
||||||
|
if conns.len() >= MAX_CONNECTIONS {
|
||||||
|
// Dropping the stream closes it. Better than queueing: the
|
||||||
|
// client side is whatever can dial loopback, so a queue is
|
||||||
|
// just a slower way to run out of execs.
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refusing connection from {} to bridged port {} — \
|
||||||
|
{} concurrent connections already open on it",
|
||||||
|
peer,
|
||||||
|
port,
|
||||||
|
MAX_CONNECTIONS
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
conns.spawn(tunnel_connection(
|
||||||
|
container_id.clone(),
|
||||||
|
target.clone(),
|
||||||
|
stream,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `accept()` on an optional listener; never completes when there is none, so it
|
||||||
|
/// can sit in a `select!` arm unconditionally.
|
||||||
|
async fn accept_optional(
|
||||||
|
listener: Option<&TcpListener>,
|
||||||
|
) -> std::io::Result<(TcpStream, SocketAddr)> {
|
||||||
|
match listener {
|
||||||
|
Some(l) => l.accept().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry one accepted host connection into the container over `socat`, after
|
||||||
|
/// deciding it is not a web page reaching into loopback.
|
||||||
|
///
|
||||||
|
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||||
|
/// reaches the container at all — not even a `docker exec`.
|
||||||
|
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||||
|
let head = match read_leading_bytes(&mut stream).await {
|
||||||
|
Ok(head) => head,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: dropping connection to bridged port {} before forwarding: {}",
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let LeadingBytes::HttpRequest { buffer, head_len } = &head {
|
||||||
|
// Authorize against the head slice only. Parsing past the blank line is
|
||||||
|
// how a request *body* gets read as headers — a cross-site `fetch` with
|
||||||
|
// a `text/plain` body is not preflighted, so it can put any line it
|
||||||
|
// likes in there.
|
||||||
|
let head_text = String::from_utf8_lossy(&buffer[..*head_len]);
|
||||||
|
if web_request_verdict(&head_text) == Verdict::RefuseCrossSite {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refused a cross-site sub-resource request to bridged port {} — \
|
||||||
|
a web page, not a login redirect",
|
||||||
|
port
|
||||||
|
);
|
||||||
|
let _ = refuse(&mut stream).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||||
|
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the first bytes of an accepted connection turned out to be.
|
||||||
|
enum LeadingBytes {
|
||||||
|
/// An HTTP request whose head we have in full. `head_len` is one past the
|
||||||
|
/// blank line; `buffer` may hold pipelined body bytes beyond it.
|
||||||
|
HttpRequest { buffer: Vec<u8>, head_len: usize },
|
||||||
|
/// Not HTTP, or HTTP we gave up on reading. Forwarded verbatim, ungated.
|
||||||
|
Opaque(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeadingBytes {
|
||||||
|
fn into_buffer(self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
LeadingBytes::HttpRequest { buffer, .. } => buffer,
|
||||||
|
LeadingBytes::Opaque(buffer) => buffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read just enough of the connection to classify it, without consuming
|
||||||
|
/// anything the caller cannot replay.
|
||||||
|
///
|
||||||
|
/// Bails out to [`LeadingBytes::Opaque`] the moment the first line proves this
|
||||||
|
/// is not HTTP, so a non-HTTP protocol pays one line of latency and no more.
|
||||||
|
/// The only hard failure is silence: a client that sends nothing within
|
||||||
|
/// [`FIRST_BYTE_TIMEOUT`] is dropped before an exec is spent on it.
|
||||||
|
async fn read_leading_bytes(stream: &mut TcpStream) -> Result<LeadingBytes, String> {
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
let mut deadline = tokio::time::Instant::now() + FIRST_BYTE_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = match tokio::time::timeout_at(deadline, stream.read(&mut chunk)).await {
|
||||||
|
Ok(Ok(0)) if buf.is_empty() => {
|
||||||
|
return Err("closed before sending anything".to_string())
|
||||||
|
}
|
||||||
|
// A half-close after some bytes is legitimate; forward what we have.
|
||||||
|
Ok(Ok(0)) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
Ok(Ok(n)) => n,
|
||||||
|
Ok(Err(e)) => return Err(format!("read failed: {}", e)),
|
||||||
|
Err(_) if buf.is_empty() => {
|
||||||
|
return Err(format!(
|
||||||
|
"sent nothing within {}s",
|
||||||
|
FIRST_BYTE_TIMEOUT.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Bytes arrived but the head never finished. Fail open: this is a
|
||||||
|
// gate on top of the bridge, not the bridge's reason to exist.
|
||||||
|
Err(_) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
};
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
|
||||||
|
// Once the first line is complete we know whether to keep reading.
|
||||||
|
if let Some(eol) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
if !is_http_request_line(&buf[..eol]) {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
deadline = deadline.max(tokio::time::Instant::now() + HEAD_TIMEOUT);
|
||||||
|
} else if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(head_len) = find_head_end(&buf) {
|
||||||
|
return Ok(LeadingBytes::HttpRequest {
|
||||||
|
buffer: buf,
|
||||||
|
head_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a first line looks like `METHOD target HTTP/1.x`.
|
||||||
|
fn is_http_request_line(line: &[u8]) -> bool {
|
||||||
|
let line = String::from_utf8_lossy(line);
|
||||||
|
let line = line.trim_end_matches(['\r', '\n']);
|
||||||
|
let mut parts = line.split(' ');
|
||||||
|
let (Some(method), Some(target), Some(version), None) =
|
||||||
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!method.is_empty()
|
||||||
|
&& method.chars().all(|c| c.is_ascii_uppercase())
|
||||||
|
&& !target.is_empty()
|
||||||
|
&& (version == "HTTP/1.1" || version == "HTTP/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index just past the blank line terminating an HTTP head, if it has arrived.
|
||||||
|
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||||
|
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||||
|
buf.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.map(|i| i + 4)
|
||||||
|
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell a refused caller why, then close. Plain text and `Connection: close` —
|
||||||
|
/// there is no session here to keep alive.
|
||||||
|
async fn refuse(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||||
|
const BODY: &str = "This port is bridged from a container by Triple-C for a sign-in \
|
||||||
|
callback. It is not an API for web pages to call.\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 403 Forbidden\r\n\
|
||||||
|
Content-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\n\
|
||||||
|
Cache-Control: no-store\r\n\
|
||||||
|
Connection: close\r\n\r\n{}",
|
||||||
|
BODY.len(),
|
||||||
|
BODY
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.shutdown().await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The gate — pure, so it can be tested without sockets
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Verdict {
|
||||||
|
/// Forward it. Either it is not a browser, or the browser says this is a
|
||||||
|
/// navigation or a same-origin request.
|
||||||
|
Allow,
|
||||||
|
/// Fetch metadata says a document on another site pulled this in as a
|
||||||
|
/// sub-resource. No login flow looks like that.
|
||||||
|
RefuseCrossSite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether an HTTP request head arriving on a bridged port may be
|
||||||
|
/// forwarded into the container.
|
||||||
|
///
|
||||||
|
/// Deliberately fail-open — see the module docs for exactly what that leaves
|
||||||
|
/// uncovered. The only refusal is the case with no innocent reading:
|
||||||
|
/// `Sec-Fetch-Site` says another site, and `Sec-Fetch-Mode` says this is not a
|
||||||
|
/// navigation. `Sec-Fetch-*` are forbidden header names, so page script cannot
|
||||||
|
/// set or clear them.
|
||||||
|
pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||||
|
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||||
|
// Skip the request line.
|
||||||
|
if lines.next().is_none() {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut site: Option<&str> = None;
|
||||||
|
let mut mode: Option<&str> = None;
|
||||||
|
for line in lines {
|
||||||
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
// A duplicate of either header is header smuggling, not a client.
|
||||||
|
// Refuse rather than pick a winner: last-occurrence-wins is what
|
||||||
|
// turns a smuggling primitive into a bypass.
|
||||||
|
"sec-fetch-site" if site.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-mode" if mode.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-site" => site = Some(value),
|
||||||
|
"sec-fetch-mode" => mode = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(site) = site else {
|
||||||
|
// No fetch metadata: a CLI, `curl`, or a browser old enough not to send
|
||||||
|
// it. Not something this gate can judge.
|
||||||
|
return Verdict::Allow;
|
||||||
|
};
|
||||||
|
if site.eq_ignore_ascii_case("same-origin") || site.eq_ignore_ascii_case("none") {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
// `navigate` is precisely the OAuth redirect: the provider sends the browser
|
||||||
|
// to `http://localhost:<port>/callback`, cross-site, as a document load.
|
||||||
|
// Refusing it would refuse the feature.
|
||||||
|
if mode.is_none_or(|m| m.eq_ignore_ascii_case("navigate")) {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
Verdict::RefuseCrossSite
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||||
|
/// ahead of anything further read from `stream`.
|
||||||
|
///
|
||||||
|
/// This exists for callers that must *inspect* the beginning of a connection
|
||||||
|
/// before deciding to forward it — the browser-view proxy reads the HTTP request
|
||||||
|
/// head off the socket to check a token, and then has to put those same bytes
|
||||||
|
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||||
|
/// re-serialising a parsed request.
|
||||||
|
pub async fn tunnel_connection_with_prelude(
|
||||||
|
container_id: String,
|
||||||
|
target: String,
|
||||||
|
stream: TcpStream,
|
||||||
|
port: u16,
|
||||||
|
prelude: Vec<u8>,
|
||||||
|
) {
|
||||||
|
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||||
|
|
||||||
|
let AttachedExec {
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
..
|
||||||
|
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
|
||||||
|
port,
|
||||||
|
target,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut host_rx, mut host_tx) = stream.into_split();
|
||||||
|
|
||||||
|
// Host → container. Runs as its own task so the container→host direction is
|
||||||
|
// never blocked behind a client that has stopped sending. Finishing this
|
||||||
|
// direction drops `input`, which closes the exec's stdin and lets socat see
|
||||||
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||||
|
let upstream = AbortOnDrop(tokio::spawn(async move {
|
||||||
|
// Bytes the caller already consumed from the socket go first, so the
|
||||||
|
// container sees the connection exactly as the client sent it.
|
||||||
|
if !prelude.is_empty()
|
||||||
|
&& (input.write_all(&prelude).await.is_err() || input.flush().await.is_err())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
|
loop {
|
||||||
|
// Idle-bounded. Without this a client that connects, sends a
|
||||||
|
// request and then never speaks or closes holds the exec open for
|
||||||
|
// as long as the container runs.
|
||||||
|
match tokio::time::timeout(IDLE_TIMEOUT, host_rx.read(&mut buf)).await {
|
||||||
|
Ok(Ok(0)) | Err(_) => break,
|
||||||
|
Ok(Ok(n)) => {
|
||||||
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
|
// stream ends, socat has exited and the connection is over. It is also the
|
||||||
|
// one that decides the connection is dead: nothing back from the container
|
||||||
|
// for `IDLE_TIMEOUT` tears the whole thing down, exec included.
|
||||||
|
while let Some(chunk) = match tokio::time::timeout(IDLE_TIMEOUT, output.next()).await {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bridged port {} idle for {}s — closing the tunnel",
|
||||||
|
port,
|
||||||
|
IDLE_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
match chunk {
|
||||||
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
|
// diagnostics out of the proxied byte stream.
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if host_tx.write_all(&message).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: socat stderr for port {}: {}",
|
||||||
|
port,
|
||||||
|
String::from_utf8_lossy(&message).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = host_tx.shutdown().await;
|
||||||
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
|
drop(upstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn head(lines: &[&str]) -> String {
|
||||||
|
format!("{}\r\n\r\n", lines.join("\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cli_callback_with_no_fetch_metadata_is_forwarded() {
|
||||||
|
// The overwhelmingly common case, and the reason the gate fails open:
|
||||||
|
// `curl`, a CLI's own probe, and anything not a browser send none of
|
||||||
|
// these headers, and none of them can be judged from the wire.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"User-Agent: curl/8.5.0",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_oauth_redirect_is_forwarded_even_though_it_is_cross_site() {
|
||||||
|
// This is the feature. The provider bounces the browser to
|
||||||
|
// `http://localhost:<port>/callback`, which is cross-site and a
|
||||||
|
// navigation. Refusing it would refuse every login the bridge exists
|
||||||
|
// for.
|
||||||
|
for site in ["cross-site", "same-site"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc&state=xyz HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
&format!("Sec-Fetch-Site: {}", site),
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
"Sec-Fetch-Dest: document",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow, "site={}", site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_form_post_callback_is_forwarded() {
|
||||||
|
// `response_mode=form_post` providers POST the callback as a
|
||||||
|
// navigation. Still a navigation, still allowed.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"POST /callback HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Origin: https://login.microsoftonline.com",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cross_site_subresource_from_a_web_page_is_refused() {
|
||||||
|
// The case the gate exists for: a page the user happens to have open
|
||||||
|
// scanning loopback and poking whatever answers.
|
||||||
|
for mode in ["cors", "no-cors", "same-origin", "websocket"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /admin HTTP/1.1",
|
||||||
|
"Host: 127.0.0.1:41733",
|
||||||
|
"Origin: https://evil.example",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
&format!("Sec-Fetch-Mode: {}", mode),
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite, "mode={}", mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_containers_own_same_origin_requests_are_forwarded() {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /style.css HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
"Sec-Fetch-Mode: no-cors",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
// `none` is a user-initiated load — typed URL, bookmark.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET / HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: none",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicated_fetch_metadata_is_refused_rather_than_resolved() {
|
||||||
|
// Last-occurrence-wins is what turns any header-smuggling primitive
|
||||||
|
// into a bypass, and no real client sends two.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /x HTTP/1.1",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: cors",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_head_is_ever_judged() {
|
||||||
|
// A cross-site `text/plain` POST is not preflighted, so its *body* is
|
||||||
|
// fully attacker-chosen. `tunnel_connection` slices at the blank line
|
||||||
|
// before calling in; this pins that the slice is what gets judged.
|
||||||
|
let raw = "POST /x HTTP/1.1\r\n\
|
||||||
|
Sec-Fetch-Site: cross-site\r\n\
|
||||||
|
Sec-Fetch-Mode: cors\r\n\
|
||||||
|
Content-Type: text/plain\r\n\r\n\
|
||||||
|
Sec-Fetch-Site: same-origin\r\n";
|
||||||
|
let head_len = find_head_end(raw.as_bytes()).expect("head terminator");
|
||||||
|
let head = &raw[..head_len];
|
||||||
|
assert!(!head.contains("same-origin"), "the forged line must be past the slice");
|
||||||
|
assert_eq!(web_request_verdict(head), Verdict::RefuseCrossSite);
|
||||||
|
|
||||||
|
// And if the slice were ever got wrong, the duplicate rule is the
|
||||||
|
// backstop: a forged `Sec-Fetch-*` line is by construction a second
|
||||||
|
// copy of one the browser already sent, which is refused outright
|
||||||
|
// rather than resolved in the forgery's favour.
|
||||||
|
assert_eq!(web_request_verdict(raw), Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_http_first_line_is_never_treated_as_a_request() {
|
||||||
|
// Bridged ports are not all HTTP. Anything whose first line is not a
|
||||||
|
// request line is forwarded verbatim rather than parsed.
|
||||||
|
assert!(!is_http_request_line(b"\x16\x03\x01\x02\x00\x01"));
|
||||||
|
assert!(!is_http_request_line(b"*1\r"));
|
||||||
|
assert!(!is_http_request_line(b"SSH-2.0-OpenSSH_9.6"));
|
||||||
|
assert!(!is_http_request_line(b"GET /x HTTP/2.0"));
|
||||||
|
assert!(!is_http_request_line(b"get /x HTTP/1.1"));
|
||||||
|
assert!(is_http_request_line(b"GET /x HTTP/1.1\r"));
|
||||||
|
assert!(is_http_request_line(b"POST /callback?code=a%20b HTTP/1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_end_is_found_for_both_terminators() {
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nBODY"), Some(18));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\nBODY"), Some(16));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_client_that_says_nothing_never_costs_a_container_exec() {
|
||||||
|
// Every accepted connection would otherwise spawn a `docker exec`
|
||||||
|
// immediately, so silence was free for the caller and expensive here.
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let result = accept.await.expect("join");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "silence should not be forwarded");
|
||||||
|
assert!(
|
||||||
|
started.elapsed() >= FIRST_BYTE_TIMEOUT,
|
||||||
|
"should have waited out the first-byte grace period"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_non_http_client_is_classified_from_its_first_line_alone() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
// Verbatim, and without waiting for a head terminator that will never
|
||||||
|
// come — the whole buffer is replayed into the tunnel.
|
||||||
|
assert!(matches!(result, LeadingBytes::Opaque(_)));
|
||||||
|
assert_eq!(result.into_buffer(), b"SSH-2.0-OpenSSH_9.6\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_http_head_is_read_whole_and_replayed_whole() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let raw = b"POST /callback HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ncode";
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(raw).await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
match &result {
|
||||||
|
LeadingBytes::HttpRequest { buffer, head_len } => {
|
||||||
|
assert_eq!(&buffer[*head_len..], b"code", "body must survive the peek");
|
||||||
|
assert!(!buffer[..*head_len].ends_with(b"code"));
|
||||||
|
}
|
||||||
|
LeadingBytes::Opaque(_) => panic!("should have been recognised as HTTP"),
|
||||||
|
}
|
||||||
|
assert_eq!(result.into_buffer(), raw.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! IPC surface for the browser view pane. The mechanism lives in
|
||||||
|
//! [`crate::browser_view`]; this file only translates between it and the
|
||||||
|
//! frontend.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||||
|
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Turn the pane on or off for a project.
|
||||||
|
///
|
||||||
|
/// Enabling probes the container and brings the viewer up when it can; a
|
||||||
|
/// container that isn't running, or one without Playwright, comes back as a
|
||||||
|
/// 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,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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?;
|
||||||
|
|
||||||
|
manager()
|
||||||
|
.start(
|
||||||
|
project_id,
|
||||||
|
container_id,
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Lets the pane say "install this" before the user asks for a view, and lets
|
||||||
|
/// them re-check after installing without toggling the feature. Read-only: it
|
||||||
|
/// runs one `node -e` and changes nothing.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_browser_view_support(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||||
|
crate::browser_view::detect::detect(&container_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install `playwright` and `@playwright/cli` into the container.
|
||||||
|
///
|
||||||
|
/// **This mutates the container**, so it is a command of its own and is only
|
||||||
|
/// ever reached by the user pressing the button — nothing here runs on tab
|
||||||
|
/// open. Progress streams on `container-progress`; the outcome carries a fresh
|
||||||
|
/// probe so the pane updates itself.
|
||||||
|
///
|
||||||
|
/// Browsers are *not* fetched here. They are hundreds of megabytes and get
|
||||||
|
/// their own action, with the size stated before the click.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_browser_view_support(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<BrowserSetupOutcome, String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
||||||
|
install::install_packages(&app_handle, &project_id, &container_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
||||||
|
/// call `chromium.launch()`) or `chrome` (the Google Chrome channel that
|
||||||
|
/// `@playwright/mcp` asks for) — along with the system libraries it needs, and
|
||||||
|
/// verify that it actually starts.
|
||||||
|
///
|
||||||
|
/// Also a mutation, also user-initiated only.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_browser_view_browser(
|
||||||
|
project_id: String,
|
||||||
|
browser: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<BrowserSetupOutcome, String> {
|
||||||
|
let target = install::BrowserTarget::parse(&browser)?;
|
||||||
|
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
||||||
|
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the view into a window of its own, or raise the one already open.
|
||||||
|
///
|
||||||
|
/// Host-side and window-only: the viewer keeps running exactly as it was, and
|
||||||
|
/// this touches neither the container nor the proxy. Requires a *live* view,
|
||||||
|
/// because a window with nothing behind it is not worth opening — the pane
|
||||||
|
/// only offers the button in that state, and this enforces it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_browser_view_popout(
|
||||||
|
project_id: String,
|
||||||
|
always_on_top: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
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."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "Triple-C".to_string());
|
||||||
|
|
||||||
|
popout::open(&app_handle, &project_id, &name, url, always_on_top)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
||||||
|
///
|
||||||
|
/// Propagates a failed close rather than reporting success: the pane restores
|
||||||
|
/// its iframe on success, and doing that with the window still up puts two
|
||||||
|
/// viewers on one browser.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_browser_view_popout(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::close(&app_handle, &project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pop-out is open, and whether it is pinned on top.
|
||||||
|
///
|
||||||
|
/// Read on every pane mount: the window outlives the pane — which is unmounted
|
||||||
|
/// whenever another Project Home sub-tab is selected — so neither fact can be
|
||||||
|
/// carried in component state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_popout_state(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<popout::PopoutState, String> {
|
||||||
|
Ok(popout::state(&app_handle, &project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the pop-out above other windows, so it can be watched while working in
|
||||||
|
/// the main one.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_popout_always_on_top(
|
||||||
|
project_id: String,
|
||||||
|
on_top: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a URL in a browser *inside* the container, published so the pane shows
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// Two uses, one action: an auth URL — where the OAuth callback listener is in
|
||||||
|
/// the container too, so the loop closes without the host being involved at all
|
||||||
|
/// — and a dev server on container loopback, which is how you watch a UI Claude
|
||||||
|
/// is building.
|
||||||
|
///
|
||||||
|
/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this
|
||||||
|
/// can never be talked into opening `file:` on the container's filesystem.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_page_in_container_browser(
|
||||||
|
project_id: String,
|
||||||
|
url: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
show_window: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||||
|
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||||
|
}
|
||||||
|
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Checking the container for Playwright…",
|
||||||
|
);
|
||||||
|
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||||
|
let opened = page::open(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
&container_id,
|
||||||
|
&detection,
|
||||||
|
trimmed,
|
||||||
|
page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// A page nobody can see is not an opened page. Opening one used to leave
|
||||||
|
// the user to go and press Start in the Browser tab themselves — and from
|
||||||
|
// the terminal's URL prompt, with no indication that was even needed.
|
||||||
|
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||||
|
let status = manager()
|
||||||
|
.status(&project_id, enabled_for(&state, &project_id))
|
||||||
|
.await;
|
||||||
|
if status.state != BrowserViewState::Running {
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Starting the viewer…",
|
||||||
|
);
|
||||||
|
manager()
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||||
|
// window of its own or it lands somewhere the user isn't looking.
|
||||||
|
if show_window {
|
||||||
|
let status = manager()
|
||||||
|
.status(&project_id, enabled_for(&state, &project_id))
|
||||||
|
.await;
|
||||||
|
if let Some(url) = status.url.as_deref() {
|
||||||
|
let name = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "Triple-C".to_string());
|
||||||
|
popout::open(&app_handle, &project_id, &name, url, false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the page this opened. The pop-out's "match window" mode calls this on
|
||||||
|
/// every settled resize, so it is deliberately cheap: one control-file write.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_container_page_viewport(
|
||||||
|
project_id: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_container_page_state(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||||
|
return Ok(page::PageState::default());
|
||||||
|
};
|
||||||
|
Ok(page::state(&container_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the page this opened, leaving the view itself running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_container_page(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||||
|
page::close(&container_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the page track the pop-out window's size as it is dragged.
|
||||||
|
///
|
||||||
|
/// Only affects a page **this app opened**: a bound browser admits no second
|
||||||
|
/// client, so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||||
|
/// Turning it on applies the window's current size immediately, so the toggle
|
||||||
|
/// has a visible effect without waiting for a drag.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_match_window(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_match_window(&project_id, enabled);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||||
|
/// pop-out's state — the pane is unmounted whenever another sub-tab is shown.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, String> {
|
||||||
|
Ok(popout::match_window(&project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project's 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
|
||||||
|
/// to be able to fail somewhere further in with a Docker error instead. The
|
||||||
|
/// `action` is folded into the message so "start the container first" arrives
|
||||||
|
/// attached to what the user was trying to do.
|
||||||
|
async fn running_container(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
project_id: &str,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let Some(container_id) = project.container_id.clone() else {
|
||||||
|
return Err(format!(
|
||||||
|
"This project has no container yet. Start it before {}.",
|
||||||
|
action
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"The container for “{}” isn't running. Start it before {}.",
|
||||||
|
project.name, action
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
//! Is there anything in this container worth watching, and can we serve a viewer
|
||||||
|
//! for it?
|
||||||
|
//!
|
||||||
|
//! Playwright is **not** in the container image — it is installed by the user or
|
||||||
|
//! by Claude, into whichever `node_modules` happens to be in scope. So detection
|
||||||
|
//! has to be done inside the container, at the moment the pane is opened, and it
|
||||||
|
//! has to produce an *actionable* answer when the pieces are missing: the pane's
|
||||||
|
//! one unforgivable failure mode would be an unexplained spinner.
|
||||||
|
//!
|
||||||
|
//! Three things must line up:
|
||||||
|
//!
|
||||||
|
//! 1. **`playwright-core`** (directly, or via `playwright`, which re-exports it),
|
||||||
|
//! 2. at a version whose `Browser` exposes **`bind()`** — the live-dashboard API
|
||||||
|
//! that publishes a browser for a viewer to attach to, and
|
||||||
|
//! 3. **`@playwright/cli`**, which ships the viewer UI itself.
|
||||||
|
//!
|
||||||
|
//! Discovery of published browsers is local-filesystem based (a cache directory
|
||||||
|
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
|
||||||
|
//! has to run *in the container* next to the browsers rather than on the host.
|
||||||
|
//!
|
||||||
|
//! ## Where a Playwright can legitimately be
|
||||||
|
//!
|
||||||
|
//! `node_modules` is not the only answer, and assuming it was is what made this
|
||||||
|
//! probe lie. `claude mcp add … npx @playwright/mcp@latest` — the way most
|
||||||
|
//! people end up with Playwright in the container — installs nothing into any
|
||||||
|
//! `node_modules`: npx unpacks the tree into `~/.npm/_npx/<hash>/node_modules`
|
||||||
|
//! and runs it from there. So that cache is searched too, every entry of it,
|
||||||
|
//! and [`PlaywrightDetection::searched`] echoes back every root actually
|
||||||
|
//! consulted so a "not found" is checkable rather than merely asserted.
|
||||||
|
//!
|
||||||
|
//! Note what that npx route can and cannot do: `@playwright/mcp` bundles a
|
||||||
|
//! `playwright-core` new enough to `bind()`, so it can satisfy points 1 and 2 —
|
||||||
|
//! but it never ships `@playwright/cli`, so it can never satisfy point 3 on its
|
||||||
|
//! own. Any message that offers it as a way to *set up* this pane is sending
|
||||||
|
//! the user down a dead end; see [`PlaywrightDetection::blocker`].
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
|
||||||
|
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
|
||||||
|
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
|
||||||
|
const MARKER: &str = "__TRIPLE_C_BROWSER_VIEW__";
|
||||||
|
|
||||||
|
/// What the probe found. Serialised straight to the frontend so the pane can
|
||||||
|
/// explain itself precisely rather than saying "not available".
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PlaywrightDetection {
|
||||||
|
/// Node's own version, if `node` ran at all.
|
||||||
|
#[serde(default)]
|
||||||
|
pub node_version: Option<String>,
|
||||||
|
/// Resolved `playwright-core` (or `playwright`) version.
|
||||||
|
#[serde(default)]
|
||||||
|
pub playwright_version: Option<String>,
|
||||||
|
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub playwright_path: Option<String>,
|
||||||
|
/// Absolute path of the resolved Playwright's own CLI entry (`cli.js`).
|
||||||
|
///
|
||||||
|
/// Both `playwright` and `playwright-core` declare one, and it is the thing
|
||||||
|
/// that installs browsers and their system libraries. Driving *that* file
|
||||||
|
/// with `node` — rather than whatever `playwright` happens to be on `PATH` —
|
||||||
|
/// is what keeps the browser install pinned to the copy this pane found.
|
||||||
|
#[serde(default)]
|
||||||
|
pub playwright_cli: Option<String>,
|
||||||
|
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_bind: bool,
|
||||||
|
/// Resolved `@playwright/cli` version — the package that serves the viewer.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cli_version: Option<String>,
|
||||||
|
/// Absolute path of `@playwright/cli`'s entry script. Invoked with `node`
|
||||||
|
/// directly rather than through its bin shim, so the viewer's PID is the one
|
||||||
|
/// we can signal.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cli_entry: Option<String>,
|
||||||
|
/// Browser bundles present in the Playwright browser cache
|
||||||
|
/// (`~/.cache/ms-playwright`), e.g. `chromium-1200`. `ffmpeg-*` is excluded
|
||||||
|
/// — it is not a browser and its presence must not read as one.
|
||||||
|
///
|
||||||
|
/// Not part of [`PlaywrightDetection::is_usable`]: the viewer serves
|
||||||
|
/// whatever has been published to it, and a browser could in principle be
|
||||||
|
/// remote. It is here because "installed but no browser to drive" is a real
|
||||||
|
/// state the pane has to be able to say out loud.
|
||||||
|
#[serde(default)]
|
||||||
|
pub browsers: Vec<String>,
|
||||||
|
/// Path to Google Chrome, if the `chrome` *channel* is installed.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::browsers`] because it is not in Playwright's cache
|
||||||
|
/// at all — the channel is an apt package. It is tracked because
|
||||||
|
/// `@playwright/mcp` asks for `channel: 'chrome'` specifically, so a
|
||||||
|
/// container with the bundled Chromium and no Chrome is set up for the
|
||||||
|
/// user's own scripts and not for the MCP plugin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chrome_channel: Option<String>,
|
||||||
|
/// The Chromium binary the *resolved* Playwright would launch, asked of the
|
||||||
|
/// build itself rather than derived from the cache listing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable: Option<String>,
|
||||||
|
/// Whether that binary is actually on disk.
|
||||||
|
///
|
||||||
|
/// False with a non-empty [`Self::browsers`] is the revision-skew case: two
|
||||||
|
/// Playwright copies in one container pin different revisions, so the cache
|
||||||
|
/// can be full of browsers and every launch still fail.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable_exists: bool,
|
||||||
|
/// The version a *script's* `require("playwright")` resolves to.
|
||||||
|
///
|
||||||
|
/// Tracked separately from [`Self::playwright_version`] because they are
|
||||||
|
/// routinely different in one directory: `@playwright/cli` pins its own
|
||||||
|
/// `playwright-core`, npm hoists that, and a separately-installed
|
||||||
|
/// `playwright` then nests a second core beside it. The viewer uses one,
|
||||||
|
/// Claude's scripts use the other.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_playwright_version: Option<String>,
|
||||||
|
/// The Chromium that copy would launch, and whether it is there. This is
|
||||||
|
/// the pair that decides whether a script Claude writes actually runs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable_exists: bool,
|
||||||
|
/// Where the probe looked, echoed back for the "not found" message.
|
||||||
|
#[serde(default)]
|
||||||
|
pub searched: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaywrightDetection {
|
||||||
|
/// Everything needed to actually serve the pane.
|
||||||
|
pub fn is_usable(&self) -> bool {
|
||||||
|
self.playwright_version.is_some() && self.has_bind && self.cli_entry.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A specific, actionable explanation of what is missing. `None` when the
|
||||||
|
/// container is ready.
|
||||||
|
///
|
||||||
|
/// Every branch names the *package* that is missing and points at this
|
||||||
|
/// pane's install action, because assembling npm commands by hand is the
|
||||||
|
/// thing that went wrong for real users. `@playwright/mcp` is named only in
|
||||||
|
/// the role it actually plays — it binds sessions automatically once
|
||||||
|
/// Playwright is present — and never as a route through setup, because it
|
||||||
|
/// does not ship `@playwright/cli` and so can never make the viewer work.
|
||||||
|
pub fn blocker(&self) -> Option<String> {
|
||||||
|
if self.node_version.is_none() {
|
||||||
|
return Some(
|
||||||
|
"Node.js isn't runnable in this container, so Playwright can't be detected."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.playwright_version.is_none() {
|
||||||
|
return Some(format!(
|
||||||
|
"Playwright isn't installed in this container. Two packages are needed: \
|
||||||
|
`playwright` (for the `browser.bind()` live-dashboard API) and \
|
||||||
|
`@playwright/cli` (the viewer UI this pane embeds). Use “Set up Playwright” \
|
||||||
|
below to install both into the container. Installing `@playwright/mcp` on \
|
||||||
|
its own is not enough — it binds sessions for you once Playwright is there, \
|
||||||
|
but it never provides the viewer. Looked in: {}.",
|
||||||
|
self.searched_text()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !self.has_bind {
|
||||||
|
return Some(format!(
|
||||||
|
"Playwright {} is installed{}, but it predates the live-dashboard API \
|
||||||
|
(`browser.bind()`). Use “Set up Playwright” below to upgrade to the latest \
|
||||||
|
`playwright`, then restart the browser Claude is driving.",
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
match self.playwright_path.as_deref() {
|
||||||
|
Some(p) => format!(" at {}", p),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.cli_entry.is_none() {
|
||||||
|
return Some(format!(
|
||||||
|
"Playwright {} is installed, but `@playwright/cli` — the package that serves \
|
||||||
|
the viewer UI — isn't, and nothing else provides it (`@playwright/mcp` does \
|
||||||
|
not). Use “Set up Playwright” below to install it. Looked in: {}.",
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.searched_text()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The revision-skew sentence, for the pane's browser step.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::blocker`] because it does not block the *viewer* —
|
||||||
|
/// the dashboard runs fine; it is the browser that cannot start. Names both
|
||||||
|
/// halves, because "install a browser" over a cache that visibly already
|
||||||
|
/// has one reads as nonsense without them.
|
||||||
|
pub fn skew_message(&self) -> Option<String> {
|
||||||
|
if !self.revision_skew() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Which half is broken changes what the user sees, so say the one that
|
||||||
|
// is. The scripts case is the one that looks like a lie: the pane is
|
||||||
|
// green, the viewer works, and every script Claude writes dies.
|
||||||
|
if self.scripts_cannot_launch() {
|
||||||
|
return Some(format!(
|
||||||
|
"This container has {}, and the viewer works — but `require(\"playwright\")` \
|
||||||
|
resolves Playwright {}, which launches {}. That file isn't there, so every \
|
||||||
|
script Claude writes fails with “Executable doesn't exist”. Two copies ended \
|
||||||
|
up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \
|
||||||
|
separately-installed `playwright` nests a second one beside it. “Set up \
|
||||||
|
Playwright” below reinstalls them as one consistent set.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.script_playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.script_chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"This container has {}, but Playwright {} launches {} — which isn't there, so \
|
||||||
|
every `chromium.launch()` fails with “Executable doesn't exist”. That happens \
|
||||||
|
when two Playwright copies share a container (typically an npx `@playwright/mcp` \
|
||||||
|
alongside this one); each pins its own browser revision. “Install Chromium” below \
|
||||||
|
fetches the revision this build needs — it runs that build's own installer, so it \
|
||||||
|
cannot pick the wrong one again.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether Playwright is present but has no browser at all to drive —
|
||||||
|
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||||
|
/// still runs, it just has nothing to show until a browser is bound.
|
||||||
|
pub fn needs_browser(&self) -> bool {
|
||||||
|
self.playwright_version.is_some()
|
||||||
|
&& self.chrome_channel.is_none()
|
||||||
|
&& (self.browsers.is_empty() || self.revision_skew())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browsers are installed, but not the revision this Playwright launches.
|
||||||
|
///
|
||||||
|
/// The container looks equipped and every `chromium.launch()` fails with
|
||||||
|
/// "Executable doesn't exist". It happens whenever two Playwright copies
|
||||||
|
/// share a container — the npx `@playwright/mcp` one and a `/workspace`
|
||||||
|
/// one — because each pins its own revision and installs into the same
|
||||||
|
/// cache. The install action fixes it: it runs the *resolved* build's own
|
||||||
|
/// CLI, so it fetches exactly the revision that was missing.
|
||||||
|
///
|
||||||
|
/// Requires the probe to have answered: an older container image, or a
|
||||||
|
/// Playwright too broken to `require`, leaves `chromium_executable` unset,
|
||||||
|
/// and "didn't answer" must not read as "skewed".
|
||||||
|
pub fn revision_skew(&self) -> bool {
|
||||||
|
!self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The copy serving the viewer would not find its browser.
|
||||||
|
fn viewer_cannot_launch(&self) -> bool {
|
||||||
|
self.chromium_executable.is_some() && !self.chromium_executable_exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `require("playwright")` — what every script Claude writes uses — would
|
||||||
|
/// not find its browser. Independent of the above, and the more common of
|
||||||
|
/// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and
|
||||||
|
/// a separately-installed `playwright` nests a second one that no browser
|
||||||
|
/// was ever downloaded for.
|
||||||
|
fn scripts_cannot_launch(&self) -> bool {
|
||||||
|
self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The searched roots as prose, so a message never trails off into "Looked
|
||||||
|
/// in: ." when the probe couldn't build a root list at all.
|
||||||
|
fn searched_text(&self) -> String {
|
||||||
|
if self.searched.is_empty() {
|
||||||
|
"the container's default module paths".to_string()
|
||||||
|
} else {
|
||||||
|
self.searched.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `node -e` probe, run as `claude` inside the container.
|
||||||
|
///
|
||||||
|
/// No shell quoting is involved: the script is a single `argv` element. The
|
||||||
|
/// script finds the global `node_modules` root and the npx cache itself, so a
|
||||||
|
/// Playwright installed with `npm i -g`, or merely *run* once through
|
||||||
|
/// `npx @playwright/mcp`, is found as readily as one in
|
||||||
|
/// `/workspace/node_modules`.
|
||||||
|
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||||
|
let output = exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_probe_output(&output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the marked JSON object out of the probe's combined output.
|
||||||
|
///
|
||||||
|
/// `exec_oneshot` interleaves stdout and stderr, and Node happily writes
|
||||||
|
/// deprecation warnings to the latter, so the payload is located by marker
|
||||||
|
/// rather than by assuming it is the whole stream.
|
||||||
|
pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, String> {
|
||||||
|
let start = output.find(MARKER).ok_or_else(|| {
|
||||||
|
let trimmed = output.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"Playwright detection produced no output. Is Node.js present in the container?"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Playwright detection failed: {}",
|
||||||
|
trimmed.lines().next_back().unwrap_or(trimmed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})? + MARKER.len();
|
||||||
|
|
||||||
|
// The payload runs to the end of that line; anything the probe's own
|
||||||
|
// children wrote afterwards is not ours.
|
||||||
|
let json = output[start..].lines().next().unwrap_or("").trim();
|
||||||
|
serde_json::from_str(json)
|
||||||
|
.map_err(|e| format!("Could not read the Playwright detection result: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The probe. Kept as one string so the quoting story is "there isn't one".
|
||||||
|
///
|
||||||
|
/// Deliberately tolerant: every lookup is individually guarded, because a
|
||||||
|
/// half-installed `node_modules` must produce a *partial* answer that
|
||||||
|
/// [`PlaywrightDetection::blocker`] can turn into advice, not an exception that
|
||||||
|
/// produces "detection failed".
|
||||||
|
const PROBE: &str = concat!(
|
||||||
|
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
||||||
|
r#"const out={node_version:process.versions.node,searched:[],has_bind:false,browsers:[]};"#,
|
||||||
|
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
||||||
|
// is cheap enough to pay for once per pane open.
|
||||||
|
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
||||||
|
r#"const home=process.env.HOME||null;"#,
|
||||||
|
// The npx cache. `npm config get cache` would be authoritative but costs a
|
||||||
|
// second npm start-up; npm exports its resolved config into the
|
||||||
|
// environment of anything it runs, so `npm_config_cache` covers the
|
||||||
|
// overridden case and `~/.npm` covers the default.
|
||||||
|
r#"const cache=process.env.npm_config_cache||(home?path.join(home,".npm"):null);"#,
|
||||||
|
// Every `_npx/<hash>` is a separate tree — `@playwright/mcp` and any other
|
||||||
|
// npx-run package each get their own — so all of them are searched, in a
|
||||||
|
// stable order, and all of them are reported in `searched`.
|
||||||
|
r#"const npx=[];if(cache){try{for(const d of fs.readdirSync(path.join(cache,"_npx")).sort()){"#,
|
||||||
|
r#"const p=path.join(cache,"_npx",d,"node_modules");"#,
|
||||||
|
r#"try{if(fs.statSync(p).isDirectory())npx.push(p);}catch(e){}}}catch(e){}}"#,
|
||||||
|
r#"const roots=[...new Set(["/workspace",process.cwd(),home?path.join(home,"node_modules"):null,g,...npx].filter(Boolean))];"#,
|
||||||
|
r#"out.searched=roots;"#,
|
||||||
|
r#"const at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#,
|
||||||
|
r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#,
|
||||||
|
// One `bin` reader for both packages: `bin` is a string for some manifests
|
||||||
|
// and an object for others, and getting that wrong on either one loses the
|
||||||
|
// entry point silently.
|
||||||
|
r#"const bin=(m,j)=>{const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});"#,
|
||||||
|
r#"const k=Object.keys(b)[0];return k?path.resolve(path.dirname(m),b[k]):null;};"#,
|
||||||
|
// `playwright-core` is what carries the typings and the browser registry, but
|
||||||
|
// it is frequently *nested*: verified against a real `npm i -g playwright
|
||||||
|
// @playwright/cli`, npm does not hoist for global installs, so the global
|
||||||
|
// root holds `playwright/` and `@playwright/cli/` and no top-level
|
||||||
|
// `playwright-core/`. Resolving only the outer `playwright` would then read
|
||||||
|
// a package that ships no `types/types.d.ts` at all and report a perfectly
|
||||||
|
// current build as "predates browser.bind()". So: hop from the wrapper to
|
||||||
|
// its own `playwright-core`, and only fall back to the wrapper's manifest.
|
||||||
|
r#"let core=res("playwright-core/package.json");"#,
|
||||||
|
r#"if(!core){const pw=res("playwright/package.json");"#,
|
||||||
|
r#"if(pw)core=at("playwright-core/package.json",path.dirname(pw))||pw;}"#,
|
||||||
|
r#"if(core){try{out.playwright_path=core;const j=JSON.parse(fs.readFileSync(core,"utf8"));"#,
|
||||||
|
r#"out.playwright_version=j.version;out.playwright_cli=bin(core,j);}catch(e){}"#,
|
||||||
|
// `bind`/`unbind` are checked against the shipped type definitions rather
|
||||||
|
// than by loading the module: it is a static read, needs no browser, and
|
||||||
|
// cannot be tripped up by a package that fails to import.
|
||||||
|
r#"try{const t=fs.readFileSync(path.join(path.dirname(core),"types","types.d.ts"),"utf8");"#,
|
||||||
|
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
|
||||||
|
r#"const cli=res("@playwright/cli/package.json");"#,
|
||||||
|
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
|
||||||
|
r#"out.cli_entry=bin(cli,j);}catch(e){}}"#,
|
||||||
|
// Browser bundles. `ffmpeg-*` lives in the same directory and is filtered
|
||||||
|
// out: it is not something that can be driven, and counting it would let
|
||||||
|
// the pane claim a browser is present when none is.
|
||||||
|
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
|
||||||
|
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
|
||||||
|
// What this Playwright would *actually launch*, and whether it is there.
|
||||||
|
//
|
||||||
|
// A cache listing is not the same question. Two Playwright copies in one
|
||||||
|
// container — the npx `@playwright/mcp` one and a `/workspace` one — pin
|
||||||
|
// different browser revisions, and each installs its own. So the cache can
|
||||||
|
// hold `chromium-1237` while the resolved build wants `chromium-1234` and
|
||||||
|
// every `chromium.launch()` dies with "Executable doesn't exist", *while
|
||||||
|
// the pane reports a browser installed*. Asking the build itself sidesteps
|
||||||
|
// revision arithmetic entirely: this is the path a launch would use.
|
||||||
|
r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#,
|
||||||
|
r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#,
|
||||||
|
r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#,
|
||||||
|
r#"if(core){const r=exe(path.dirname(core));"#,
|
||||||
|
r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#,
|
||||||
|
// And separately: what a *script* gets. `require("playwright")` is what
|
||||||
|
// every Playwright example writes, and it resolves the wrapper — which
|
||||||
|
// carries its own nested `playwright-core` whenever npm could not settle on
|
||||||
|
// one version. That copy can want a different browser revision than the one
|
||||||
|
// the viewer's copy installed, so it is asked its own question.
|
||||||
|
r#"try{const w=res("playwright/package.json");"#,
|
||||||
|
r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#,
|
||||||
|
r#"const wc=at("playwright-core/package.json",path.dirname(w));"#,
|
||||||
|
r#"const r=exe(path.dirname(wc||w));"#,
|
||||||
|
r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}catch(e){}"#,
|
||||||
|
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||||
|
// is looked for where apt puts it.
|
||||||
|
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
||||||
|
r#"if(fs.existsSync(p)){out.chrome_channel=p;break;}}}catch(e){}"#,
|
||||||
|
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn payload(json: &str) -> String {
|
||||||
|
format!("some npm noise\n{}{}\n", MARKER, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_install_is_usable() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"cli_version":"0.1.18","cli_entry":"/workspace/node_modules/@playwright/cli/playwright-cli.js","searched":["/workspace"]}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stderr_noise_before_and_after_the_payload_is_ignored() {
|
||||||
|
let out = format!(
|
||||||
|
"(node:41) Warning: something\n{}{}\nnpm notice trailing\n",
|
||||||
|
MARKER, r#"{"node_version":"22.11.0","has_bind":false}"#
|
||||||
|
);
|
||||||
|
let d = parse_probe_output(&out).unwrap();
|
||||||
|
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_playwright_names_both_packages_and_where_we_looked() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/a1/node_modules"]}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!d.is_usable());
|
||||||
|
let msg = d.blocker().unwrap();
|
||||||
|
// The two packages that actually have to be there, by name.
|
||||||
|
assert!(msg.contains("`playwright`"), "{}", msg);
|
||||||
|
assert!(msg.contains("`@playwright/cli`"), "{}", msg);
|
||||||
|
assert!(msg.contains("browser.bind"), "{}", msg);
|
||||||
|
// Every root consulted, including the npx cache, so the claim is checkable.
|
||||||
|
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
|
||||||
|
assert!(msg.contains("/home/claude/.npm/_npx/a1/node_modules"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_message_offers_playwright_mcp_as_a_way_through_setup() {
|
||||||
|
// It bundles a playwright-core new enough to bind, but never ships the
|
||||||
|
// viewer — so proposing it as an install route is a dead end, which is
|
||||||
|
// exactly what a user hit. It may only be named for what it does do.
|
||||||
|
for json in [
|
||||||
|
r#"{"node_version":"22.11.0","searched":["/workspace"]}"#,
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false}"#,
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
|
||||||
|
] {
|
||||||
|
let msg = parse_probe_output(&payload(json)).unwrap().blocker().unwrap();
|
||||||
|
let offers_install = msg.contains("install `@playwright/mcp`")
|
||||||
|
|| msg.contains("or use `@playwright/mcp`")
|
||||||
|
|| msg.contains("npm i -D @playwright/mcp")
|
||||||
|
|| msg.contains("npm i -g @playwright/mcp");
|
||||||
|
assert!(!offers_install, "{}", msg);
|
||||||
|
// And every message points at the one action that does work.
|
||||||
|
assert!(msg.contains("Set up Playwright"), "{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","playwright_path":"/workspace/node_modules/playwright/package.json","has_bind":false,"cli_entry":"/x/cli.js"}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let msg = d.blocker().unwrap();
|
||||||
|
assert!(msg.contains("1.44.0"), "{}", msg);
|
||||||
|
assert!(msg.contains("/workspace/node_modules/playwright"), "{}", msg);
|
||||||
|
assert!(msg.contains("Set up Playwright"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_npx_cached_playwright_counts_as_installed() {
|
||||||
|
// What `claude mcp add … npx @playwright/mcp@latest` leaves behind: a
|
||||||
|
// real playwright-core, in no `node_modules` the old probe looked at.
|
||||||
|
// It satisfies bind — and nothing else, because npx never brings the
|
||||||
|
// viewer with it.
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","#,
|
||||||
|
r#""playwright_path":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/package.json","#,
|
||||||
|
r#""playwright_cli":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js","#,
|
||||||
|
r#""has_bind":true,"#,
|
||||||
|
r#""searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/9f/node_modules"]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(d.playwright_version.as_deref(), Some("1.62.1"));
|
||||||
|
assert!(d.has_bind);
|
||||||
|
assert_eq!(
|
||||||
|
d.playwright_cli.as_deref(),
|
||||||
|
Some("/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js")
|
||||||
|
);
|
||||||
|
// Still not usable, and the message says why: the viewer is missing.
|
||||||
|
assert!(!d.is_usable());
|
||||||
|
let msg = d.blocker().unwrap();
|
||||||
|
assert!(msg.contains("@playwright/cli"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_searches_the_npx_cache_as_well_as_the_module_roots() {
|
||||||
|
// The roots are built inside the probe, so this is the only place the
|
||||||
|
// set can be asserted without a container. Each fragment is load-bearing:
|
||||||
|
// dropping any one of them is how an install becomes invisible.
|
||||||
|
assert!(PROBE.contains(r#""/workspace""#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("process.cwd()"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains(r#"path.join(home,"node_modules")"#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("npm root -g"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains(r#"path.join(cache,"_npx")"#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("npm_config_cache"), "{}", PROBE);
|
||||||
|
// Every one of them, not just the first hit, and all of them reported.
|
||||||
|
assert!(PROBE.contains("...npx"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("out.searched=roots"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partial_tree_still_answers_rather_than_failing() {
|
||||||
|
// Playwright resolved, but its manifest unreadable and no viewer: the
|
||||||
|
// probe's guards must still produce a parseable payload carrying what
|
||||||
|
// it did learn, because that is what the message is built from.
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","has_bind":false,"searched":["/workspace"],"browsers":["chromium-1200"]}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
|
||||||
|
assert_eq!(d.browsers, vec!["chromium-1200".to_string()]);
|
||||||
|
assert!(d.blocker().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_playwright_with_no_browser_bundle_is_flagged_without_blocking() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
// Serving the viewer is possible; there is just nothing to drive yet.
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
assert!(d.needs_browser());
|
||||||
|
|
||||||
|
let with_browser = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1200"]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!with_browser.needs_browser());
|
||||||
|
|
||||||
|
// The Chrome channel counts too — it is an apt package rather than a
|
||||||
|
// Playwright download, so it never appears in `browsers`, and
|
||||||
|
// `@playwright/mcp` is the caller that asks for it.
|
||||||
|
let chrome_only = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[],"#,
|
||||||
|
r#""chrome_channel":"/usr/bin/google-chrome-stable"}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!chrome_only.needs_browser());
|
||||||
|
assert_eq!(
|
||||||
|
chrome_only.chrome_channel.as_deref(),
|
||||||
|
Some("/usr/bin/google-chrome-stable")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_looks_for_the_chrome_channel_where_apt_puts_it() {
|
||||||
|
assert!(PROBE.contains("google-chrome-stable"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_asks_playwright_what_it_would_launch() {
|
||||||
|
// Not derived from the cache listing — asked of the build, because the
|
||||||
|
// cache can hold a browser this build will never launch.
|
||||||
|
assert!(PROBE.contains("executablePath"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container carrying browsers from a *different* Playwright copy.
|
||||||
|
fn skewed() -> PlaywrightDetection {
|
||||||
|
parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"],"#,
|
||||||
|
r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#,
|
||||||
|
r#""chromium_executable_exists":false}"#,
|
||||||
|
)))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() {
|
||||||
|
let d = skewed();
|
||||||
|
// The viewer still serves — it is the browser that cannot start.
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
assert!(d.revision_skew());
|
||||||
|
assert!(d.needs_browser(), "a browser that cannot launch is not a browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_skew_message_names_both_revisions_and_the_way_out() {
|
||||||
|
let msg = skewed().skew_message().unwrap();
|
||||||
|
assert!(msg.contains("chromium-1237"), "{}", msg); // what is there
|
||||||
|
assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants
|
||||||
|
assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_chrome_channel_covers_a_skewed_cache() {
|
||||||
|
// The channel is an apt binary at a fixed path, so a revision mismatch
|
||||||
|
// cannot affect it: there is still something to drive.
|
||||||
|
let mut d = skewed();
|
||||||
|
d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_probe_that_could_not_answer_is_not_reported_as_skew() {
|
||||||
|
// Older container, or a Playwright too broken to `require`: unset is
|
||||||
|
// "unknown", and unknown must never render as "your browsers are wrong".
|
||||||
|
let d = parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"]}"#,
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!d.revision_skew());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
assert_eq!(d.skew_message(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_viewer_package_is_reported_separately() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!d.is_usable());
|
||||||
|
assert!(d.blocker().unwrap().contains("@playwright/cli"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_container_without_node_says_so() {
|
||||||
|
let d = parse_probe_output(&payload(r#"{"has_bind":false}"#)).unwrap();
|
||||||
|
assert!(d.blocker().unwrap().contains("Node.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unmarked_stream_surfaces_the_containers_own_error() {
|
||||||
|
let err = parse_probe_output("sh: 1: node: not found\n").unwrap_err();
|
||||||
|
assert!(err.contains("node: not found"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_stream_is_explained_rather_than_parsed() {
|
||||||
|
let err = parse_probe_output(" \n").unwrap_err();
|
||||||
|
assert!(err.contains("no output"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_reads_bind_from_the_nested_core_of_a_wrapper_install() {
|
||||||
|
// `npm i -g playwright` leaves `playwright-core` under
|
||||||
|
// `playwright/node_modules`, and the wrapper ships no
|
||||||
|
// `types/types.d.ts` — so without this hop a current build reports
|
||||||
|
// `has_bind: false`. Verified against a real global install.
|
||||||
|
assert!(
|
||||||
|
PROBE.contains(r#"at("playwright-core/package.json",path.dirname(pw))"#),
|
||||||
|
"{}",
|
||||||
|
PROBE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
|
||||||
|
// It is passed straight to `node -e`; a stray single quote would only
|
||||||
|
// matter if someone later routed it through a shell, and a newline
|
||||||
|
// would break the marker-line contract in `parse_probe_output`.
|
||||||
|
assert!(!PROBE.contains('\n'));
|
||||||
|
assert!(PROBE.contains(MARKER));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,977 @@
|
|||||||
|
//! Browser view — watch, and take over, the browser Claude is driving.
|
||||||
|
//!
|
||||||
|
//! ## What is actually being watched
|
||||||
|
//!
|
||||||
|
//! Playwright ships a live dashboard. A script inside the container calls
|
||||||
|
//! `await browser.bind('claude')`, which publishes a descriptor for the running
|
||||||
|
//! browser into `~/.cache/ms-playwright/b/`; `@playwright/mcp` does this for you.
|
||||||
|
//! `playwright-cli show --host 127.0.0.1 --port <p>` then serves a React viewer
|
||||||
|
//! that watches that directory, connects to the published browser, and gives you
|
||||||
|
//! a CDP screencast with full mouse and keyboard takeover — all of which works
|
||||||
|
//! with `headless: true`, which is the only thing that could work in a container.
|
||||||
|
//!
|
||||||
|
//! Discovery is *local filesystem*, so the viewer has to run in the same
|
||||||
|
//! container as the browsers. There is nothing a host-side viewer could see.
|
||||||
|
//!
|
||||||
|
//! ## Getting it onto the screen safely
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! webview <iframe> host container
|
||||||
|
//! ──────────────── ──── ─────────
|
||||||
|
//! http://127.0.0.1:47820/index.html
|
||||||
|
//! ?ws=…&token=… ────► BrowserViewProxy ──socat exec──► playwright-cli show
|
||||||
|
//! (token gate) (Docker API) 127.0.0.1:39321
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The proxy is the *only* host-bound socket, and it authenticates before a byte
|
||||||
|
//! reaches the container — see [`proxy`] for the gate, and for why the auth
|
||||||
|
//! bridge's unauthenticated [`PortForward`](crate::auth_bridge::tunnel::PortForward)
|
||||||
|
//! is deliberately not used to carry this port. The container-side viewer port is
|
||||||
|
//! additionally *reserved* with
|
||||||
|
//! [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`], so that a project which
|
||||||
|
//! also has the auth bridge on cannot end up with the viewer mirrored onto the
|
||||||
|
//! host a second time, ungated.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle
|
||||||
|
//!
|
||||||
|
//! 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)` → 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 |
|
||||||
|
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
|
||||||
|
//! | App exit | [`BrowserViewManager::stop_all`] |
|
||||||
|
//!
|
||||||
|
//! [`BrowserViewManager::stop`] awaits the supervisor, so the host port is
|
||||||
|
//! provably released before it returns.
|
||||||
|
//!
|
||||||
|
//! One honest gap, verified rather than assumed: `playwright-cli show` is only
|
||||||
|
//! a launcher — the dashboard it starts reparents to PID 1 and survives the
|
||||||
|
//! exec that spawned it. Every ordinary teardown path above calls
|
||||||
|
//! [`kill_dashboard`], which does stop it, but a *hard* app crash leaves the
|
||||||
|
//! dashboard running inside the container until the container stops. That
|
||||||
|
//! 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 — 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;
|
||||||
|
pub mod install;
|
||||||
|
pub mod page;
|
||||||
|
pub mod popout;
|
||||||
|
pub mod proxy;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::auth_bridge::proc_net::{self, PortFamily};
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
|
use detect::PlaywrightDetection;
|
||||||
|
use proxy::BrowserViewProxy;
|
||||||
|
|
||||||
|
/// Emitted whenever a project's browser view starts, stops or fails.
|
||||||
|
/// Payload: `{ project_id, status: BrowserViewStatus }`.
|
||||||
|
const BROWSER_VIEW_EVENT: &str = "browser-view-changed";
|
||||||
|
|
||||||
|
/// Container-side ports the viewer may bind, tried in order. The dashboard is a
|
||||||
|
/// per-workspace singleton inside the container, so only one is ever in use at
|
||||||
|
/// a time; the range exists only so an unrelated service already sitting on the
|
||||||
|
/// first port doesn't take the feature down.
|
||||||
|
///
|
||||||
|
/// This *is* [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] — the bridge must
|
||||||
|
/// never mirror these, so the two cannot be allowed to drift.
|
||||||
|
const VIEWER_PORTS: std::ops::RangeInclusive<u16> = crate::auth_bridge::RESERVED_CONTAINER_PORTS;
|
||||||
|
|
||||||
|
/// How often the supervisor re-checks that the session still has a reason to
|
||||||
|
/// exist. Matches the auth bridge's cadence.
|
||||||
|
const SUPERVISE_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Supervisor ticks between HTTP liveness probes of the viewer. The two cheap
|
||||||
|
/// checks run every tick; this one costs a container exec, so it runs at 1/5
|
||||||
|
/// the rate (~10s).
|
||||||
|
const LIVENESS_EVERY: u32 = 5;
|
||||||
|
|
||||||
|
/// Ceiling on one readiness/liveness probe. Enforced inside the container by
|
||||||
|
/// Node and again here, so neither a wedged daemon nor a wedged exec can stall
|
||||||
|
/// the supervisor.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
|
/// How long to wait for `playwright-cli show` to start answering HTTP.
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const READY_POLL: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC response model
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BrowserViewState {
|
||||||
|
/// Not running. Either never started, or stopped.
|
||||||
|
Off,
|
||||||
|
/// Running and reachable at `url`.
|
||||||
|
Running,
|
||||||
|
/// The container can't serve this — see `message` for what to install.
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BrowserViewStatus {
|
||||||
|
/// 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.
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub host_port: Option<u16>,
|
||||||
|
pub container_port: Option<u16>,
|
||||||
|
/// RFC 3339 timestamp of when the viewer came up.
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
/// What was found in the container. Present even when unusable, because
|
||||||
|
/// that is exactly when the user needs to see it.
|
||||||
|
pub detection: Option<PlaywrightDetection>,
|
||||||
|
/// Human-readable explanation, set whenever `state` isn't `Running`.
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserViewStatus {
|
||||||
|
fn off(enabled: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
state: BrowserViewState::Off,
|
||||||
|
url: None,
|
||||||
|
host_port: None,
|
||||||
|
container_port: None,
|
||||||
|
started_at: None,
|
||||||
|
detection: None,
|
||||||
|
message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable(enabled: bool, detection: PlaywrightDetection, message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
state: BrowserViewState::Unavailable,
|
||||||
|
detection: Some(detection),
|
||||||
|
message: Some(message),
|
||||||
|
..Self::off(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manager
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Everything a live session exposes to `status()`. Fixed once the session is
|
||||||
|
/// up, so it can be cloned out from under the map lock.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SessionMeta {
|
||||||
|
url: String,
|
||||||
|
host_port: u16,
|
||||||
|
container_port: u16,
|
||||||
|
started_at: String,
|
||||||
|
detection: PlaywrightDetection,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
/// Distinguishes this supervisor from a later one for the same project, so
|
||||||
|
/// a supervisor that exits late can't evict its replacement.
|
||||||
|
epoch: u64,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
meta: SessionMeta,
|
||||||
|
supervisor: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide handle.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* a field on `AppState`: keeping it here means the feature
|
||||||
|
/// needs no edit to `lib.rs` beyond declaring the module and registering the
|
||||||
|
/// commands, and it lets teardown paths reach it without threading state.
|
||||||
|
pub fn manager() -> &'static Arc<BrowserViewManager> {
|
||||||
|
static MANAGER: OnceLock<Arc<BrowserViewManager>> = OnceLock::new();
|
||||||
|
MANAGER.get_or_init(|| Arc::new(BrowserViewManager::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserViewManager {
|
||||||
|
/// Current status without touching the container.
|
||||||
|
///
|
||||||
|
/// `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,
|
||||||
|
state: BrowserViewState::Running,
|
||||||
|
url: Some(session.meta.url.clone()),
|
||||||
|
host_port: Some(session.meta.host_port),
|
||||||
|
container_port: Some(session.meta.container_port),
|
||||||
|
started_at: Some(session.meta.started_at.clone()),
|
||||||
|
detection: Some(session.meta.detection.clone()),
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
None => BrowserViewStatus::off(enabled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the container and, if it can serve a viewer, bring one up.
|
||||||
|
///
|
||||||
|
/// 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,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) -> Result<BrowserViewStatus, String> {
|
||||||
|
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.
|
||||||
|
let already_live = self
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|s| !s.supervisor.is_finished());
|
||||||
|
if already_live {
|
||||||
|
return Ok(self.status(&project_id, true).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let detection = detect::detect(&container_id).await?;
|
||||||
|
if !detection.is_usable() {
|
||||||
|
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||||
|
"Playwright is present but incomplete in this container.".to_string()
|
||||||
|
});
|
||||||
|
let status = BrowserViewStatus::unavailable(true, detection, blocker);
|
||||||
|
emit(&app, &project_id, &status);
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
// `is_usable()` already established this, so the fallback is unreachable.
|
||||||
|
let cli_entry = detection.cli_entry.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// The dashboard is a per-workspace singleton keyed on a unix socket in
|
||||||
|
// the temp dir, not on a port. Verified: while one is running, a second
|
||||||
|
// `show --port` prints "Dashboard is running pid=…", exits 0, and
|
||||||
|
// *ignores the port you asked for*. So always reclaim first — including
|
||||||
|
// a daemon this app orphaned in an earlier run, since it outlives us.
|
||||||
|
// Doing this before choosing a port also frees the one a previous
|
||||||
|
// session was using, so sessions don't walk up the range. Best-effort:
|
||||||
|
// a container with no dashboard makes this a no-op.
|
||||||
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
|
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||||
|
|
||||||
|
let token = generate_token();
|
||||||
|
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||||
|
// no need to go back to /proc/net to work it out.
|
||||||
|
let proxy = match BrowserViewProxy::bind(
|
||||||
|
container_id.clone(),
|
||||||
|
container_port,
|
||||||
|
PortFamily::V4,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta = SessionMeta {
|
||||||
|
url: build_url(proxy.port, &entry_path, &token),
|
||||||
|
host_port: proxy.port,
|
||||||
|
container_port,
|
||||||
|
started_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
detection,
|
||||||
|
};
|
||||||
|
|
||||||
|
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
let supervisor = tokio::spawn(supervise(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
cli_entry,
|
||||||
|
container_port,
|
||||||
|
epoch,
|
||||||
|
app.clone(),
|
||||||
|
store,
|
||||||
|
self.sessions.clone(),
|
||||||
|
cancel_rx,
|
||||||
|
proxy,
|
||||||
|
));
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Browser view: project {} → 127.0.0.1:{} → container 127.0.0.1:{}",
|
||||||
|
project_id,
|
||||||
|
meta.host_port,
|
||||||
|
container_port
|
||||||
|
);
|
||||||
|
|
||||||
|
self.sessions.lock().await.insert(
|
||||||
|
project_id.clone(),
|
||||||
|
Session {
|
||||||
|
epoch,
|
||||||
|
cancel: cancel_tx,
|
||||||
|
meta,
|
||||||
|
supervisor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// 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);
|
||||||
|
if let Some(session) = session {
|
||||||
|
let _ = session.cancel.send(true);
|
||||||
|
let _ = session.supervisor.await;
|
||||||
|
log::info!("Browser view: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every view. Used on app exit.
|
||||||
|
pub async fn stop_all(&self) {
|
||||||
|
let sessions: Vec<(String, Session)> = self.sessions.lock().await.drain().collect();
|
||||||
|
for (project_id, session) in sessions {
|
||||||
|
let _ = session.cancel.send(true);
|
||||||
|
let _ = session.supervisor.await;
|
||||||
|
log::info!("Browser view: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Supervisor
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Owns the proxy and the viewer process for one session and is the only thing
|
||||||
|
/// that tears them down, so a session can't half-die.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn supervise(
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
cli_entry: String,
|
||||||
|
container_port: u16,
|
||||||
|
epoch: u64,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
sessions: SessionMap,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
mut proxy: BrowserViewProxy,
|
||||||
|
) {
|
||||||
|
let mut ticks: u32 = 0;
|
||||||
|
loop {
|
||||||
|
if store.get(&project_id).is_none() {
|
||||||
|
log::info!("Browser view: project {} is gone — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
log::info!(
|
||||||
|
"Browser view: container for project {} is no longer running — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// The dashboard is a detached daemon, so there is no process handle to
|
||||||
|
// watch: liveness has to be an actual request. That costs an exec, so
|
||||||
|
// it runs at a coarser cadence than the two cheap checks above.
|
||||||
|
ticks = ticks.wrapping_add(1);
|
||||||
|
if ticks % LIVENESS_EVERY == 0 {
|
||||||
|
// Cancellation races the probe, not just the sleep, so stopping the
|
||||||
|
// view never waits out an in-flight exec.
|
||||||
|
let alive = tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
|
||||||
|
};
|
||||||
|
if !alive {
|
||||||
|
log::warn!(
|
||||||
|
"Browser view: the viewer for project {} stopped answering — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
_ = tokio::time::sleep(SUPERVISE_INTERVAL) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.shutdown().await;
|
||||||
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
|
// Deregister, unless a newer session has already taken this project's slot.
|
||||||
|
let superseded = {
|
||||||
|
let mut map = sessions.lock().await;
|
||||||
|
match map.get(&project_id) {
|
||||||
|
Some(session) if session.epoch == epoch => {
|
||||||
|
map.remove(&project_id);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
// Someone else owns this project now: `stop` removes the session
|
||||||
|
// from the map *before* awaiting this task, and teardown below is
|
||||||
|
// seconds of Docker work, so a restart in that window is ordinary.
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything past here speaks for the project as a whole, so a superseded
|
||||||
|
// supervisor must say nothing: closing the pop-out would destroy the *new*
|
||||||
|
// session's window, and the off-status would report a running view as
|
||||||
|
// stopped.
|
||||||
|
if superseded {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pop-out outlives the tab, so nothing else would take it down: the
|
||||||
|
// window would sit there showing a frozen last frame of a viewer that no
|
||||||
|
// longer exists. The session owns it, and this is where the session ends.
|
||||||
|
let _ = popout::close(&app, &project_id);
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The viewer process
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Where the detached viewer's own output goes, so a failed start still has
|
||||||
|
/// something to show the user.
|
||||||
|
const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
|
||||||
|
|
||||||
|
/// Start `playwright-cli show`, detached.
|
||||||
|
///
|
||||||
|
/// `playwright-cli show` is a *launcher*: verified that it spawns
|
||||||
|
/// `playwright-core/lib/entry/dashboardApp.js`, which reparents to PID 1 and
|
||||||
|
/// outlives both the launcher and the exec that started it. So there is no
|
||||||
|
/// point tying a process lifetime to the exec's stdin — signalling the launcher
|
||||||
|
/// leaves the dashboard bound to its port and still serving. Teardown is
|
||||||
|
/// [`kill_dashboard`], which is the only thing verified to actually stop it.
|
||||||
|
///
|
||||||
|
/// Consequently this is a fire-and-forget exec: the launcher's output is
|
||||||
|
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
|
||||||
|
/// rather than waiting on an inherited stdout, and so a failure has a trail),
|
||||||
|
/// and readiness is established by [`wait_until_ready`] instead.
|
||||||
|
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
|
||||||
|
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
|
||||||
|
// launch; the container may have no egress, and we don't want to wait out a
|
||||||
|
// DNS timeout before the dashboard binds.
|
||||||
|
let script = format!(
|
||||||
|
"{}; NO_UPDATE_NOTIFIER=1 nohup node {} show --host 127.0.0.1 --port {} >{} 2>&1 &",
|
||||||
|
WORKDIR_PREFIX,
|
||||||
|
shell_quote(cli_entry),
|
||||||
|
port,
|
||||||
|
VIEWER_LOG
|
||||||
|
);
|
||||||
|
exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), script],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dashboard singleton is keyed on a hash of the working directory, so
|
||||||
|
/// `show` and `show --kill` must agree on one. `exec_oneshot` doesn't set a
|
||||||
|
/// working directory (it inherits the image's), and `/workspace` is both what
|
||||||
|
/// the image sets today and where Claude actually runs — but pinning it here
|
||||||
|
/// means a change to the image can't silently split the two into different
|
||||||
|
/// singletons, leaving a dashboard nothing can kill.
|
||||||
|
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
|
||||||
|
|
||||||
|
/// Stop the dashboard daemon. Verified to free the port and stop answering.
|
||||||
|
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
|
||||||
|
let script = format!(
|
||||||
|
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
|
||||||
|
WORKDIR_PREFIX,
|
||||||
|
shell_quote(cli_entry)
|
||||||
|
);
|
||||||
|
exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), script],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a failed start into something the user can act on.
|
||||||
|
///
|
||||||
|
/// The one failure worth naming is the singleton clash: if a dashboard we
|
||||||
|
/// couldn't reclaim is still alive, the launcher exits 0 having printed
|
||||||
|
/// "Dashboard is running pid=…" and having silently ignored the port we asked
|
||||||
|
/// for, so all the caller sees is a port that never answers.
|
||||||
|
fn explain_start_failure(err: &str, log: &str) -> String {
|
||||||
|
let log = log.trim();
|
||||||
|
if log.contains("Dashboard is running") {
|
||||||
|
return format!(
|
||||||
|
"Another Playwright dashboard is already running in this container and would not \
|
||||||
|
give up its port. Stop it from a terminal in the container with \
|
||||||
|
`npx playwright-cli show --kill`, then try again.\n\nViewer output:\n{}",
|
||||||
|
log
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if log.is_empty() {
|
||||||
|
err.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}\n\nViewer output:\n{}", err, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tail of the viewer's own output, for a start that didn't come up.
|
||||||
|
async fn read_viewer_log(container_id: &str) -> String {
|
||||||
|
exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Readiness, ports, URLs
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// How many free ports a start will try before giving up.
|
||||||
|
///
|
||||||
|
/// More than one because port choice is a check-then-bind: the free list comes
|
||||||
|
/// from a snapshot of the container's `/proc/net/tcp`, and anything in the
|
||||||
|
/// container may bind the port we picked before the dashboard gets to it. One
|
||||||
|
/// retry per lost race is the recovery; the cap is what stops a container that
|
||||||
|
/// binds every candidate from holding a start open for
|
||||||
|
/// `MAX_PORT_ATTEMPTS × READY_TIMEOUT`.
|
||||||
|
const MAX_PORT_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
|
/// Get a viewer listening inside the container and return the port it is on
|
||||||
|
/// plus the path the pane should load.
|
||||||
|
///
|
||||||
|
/// ## The check/bind race
|
||||||
|
///
|
||||||
|
/// [`pick_viewer_port`] reads a *snapshot* of container listeners; the dashboard
|
||||||
|
/// binds some milliseconds later. Nothing here can make that atomic — the bind
|
||||||
|
/// happens in another process, in another namespace, and `playwright-cli show`
|
||||||
|
/// reports the port it actually took only on a first-ever start (see
|
||||||
|
/// [`wait_until_ready`]). What is possible is to stop treating the first
|
||||||
|
/// candidate as the only one: if the port we picked does not come up, walk to
|
||||||
|
/// the next free candidate rather than failing the whole start.
|
||||||
|
///
|
||||||
|
/// Residual, stated rather than glossed: a container-side process that binds the
|
||||||
|
/// candidate port *and answers HTTP* is indistinguishable from the dashboard at
|
||||||
|
/// this layer, and the pane would then front it. What contains that is
|
||||||
|
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||||
|
/// iframe is sandboxed — not this function.
|
||||||
|
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||||
|
let mut tried: Vec<u16> = Vec::new();
|
||||||
|
let mut last: Option<String> = None;
|
||||||
|
|
||||||
|
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||||
|
// Re-read the listener snapshot each attempt: the port that was free a
|
||||||
|
// moment ago is exactly the one we may have just lost.
|
||||||
|
let port = match pick_viewer_port(container_id, &tried).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
// Report why the *attempts* failed, not just "nothing free":
|
||||||
|
// the exhausted range is the symptom, the last start failure is
|
||||||
|
// the thing the user can act on.
|
||||||
|
return Err(match last {
|
||||||
|
Some(prev) => format!("{} ({})", e, prev),
|
||||||
|
None => e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tried.push(port);
|
||||||
|
|
||||||
|
launch_viewer(container_id, cli_entry, port).await?;
|
||||||
|
|
||||||
|
// Wait for it to actually answer, and learn the entry URL while we're
|
||||||
|
// there — see `probe_entry_path` for why that matters. This, not the
|
||||||
|
// launcher's stdout, is the readiness signal: verified that the
|
||||||
|
// "Listening on …" line is printed only on the very first start.
|
||||||
|
match wait_until_ready(container_id, port).await {
|
||||||
|
Ok(path) => return Ok((port, path)),
|
||||||
|
Err(e) => {
|
||||||
|
let log = read_viewer_log(container_id).await;
|
||||||
|
// Always kill before retrying: the dashboard is a singleton, so
|
||||||
|
// a launcher that came up on some *other* port would otherwise
|
||||||
|
// make every further attempt a no-op that silently ignores the
|
||||||
|
// port we asked for.
|
||||||
|
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||||
|
last = Some(explain_start_failure(&e, &log));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last.unwrap_or_else(|| "The Playwright viewer did not start.".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||||
|
/// and that this start has not already tried.
|
||||||
|
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||||
|
let text = exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec![
|
||||||
|
// Absolute path, deliberately, for the same reason the auth bridge
|
||||||
|
// uses one: `container/Dockerfile` puts a container-writable
|
||||||
|
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||||
|
// can rebind to a shim. A shimmed listener list is a shimmed answer
|
||||||
|
// to "which port is free" — i.e. the container choosing which port
|
||||||
|
// the viewer, and therefore the host-side proxy, ends up on.
|
||||||
|
"/usr/bin/cat".to_string(),
|
||||||
|
"/proc/net/tcp".to_string(),
|
||||||
|
"/proc/net/tcp6".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let taken = proc_net::parse_loopback_listeners(&text);
|
||||||
|
VIEWER_PORTS
|
||||||
|
.clone()
|
||||||
|
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||||
|
VIEWER_PORTS.start(),
|
||||||
|
VIEWER_PORTS.end()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the viewer until it answers, and return the path the pane should load.
|
||||||
|
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
|
||||||
|
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let last = match probe_entry_path(container_id, port).await {
|
||||||
|
Ok(path) => return Ok(path),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"The Playwright viewer did not start listening on container port {} within {}s ({}).",
|
||||||
|
port,
|
||||||
|
READY_TIMEOUT.as_secs(),
|
||||||
|
last
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
|
||||||
|
|
||||||
|
/// Ask the viewer, from inside the container, what it wants to be loaded as.
|
||||||
|
///
|
||||||
|
/// `GET /` answers `302 Location: /index.html?ws=<guid>`, where the guid is the
|
||||||
|
/// dashboard's own per-run capability for its WebSocket. Resolving that here and
|
||||||
|
/// pointing the iframe straight at the final URL means the pane never traverses
|
||||||
|
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
|
||||||
|
/// gate wants and would leave a fresh connection to be authorised with nothing.
|
||||||
|
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
|
||||||
|
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
|
||||||
|
// The request is bounded on both sides. Verified: the dashboard answers a
|
||||||
|
// bad WebSocket path by holding the socket open forever rather than
|
||||||
|
// erroring, so "no reply" is a state this probe has to be able to leave —
|
||||||
|
// otherwise a wedged daemon would wedge the supervisor, and `stop()` waits
|
||||||
|
// on the supervisor.
|
||||||
|
let script = format!(
|
||||||
|
r#"const q=require("http").get({{host:"127.0.0.1",port:{},path:"/",headers:{{host:"127.0.0.1:{}"}}}},r=>{{process.stdout.write("\n{}"+r.statusCode+" "+(r.headers.location||"/")+"\n");r.resume();process.exit(0);}});q.on("error",e=>{{process.stderr.write(String(e.message));process.exit(1);}});q.setTimeout({},()=>{{process.stderr.write("timed out waiting for the viewer");q.destroy();process.exit(1);}});"#,
|
||||||
|
port,
|
||||||
|
port,
|
||||||
|
PROBE_MARKER,
|
||||||
|
PROBE_TIMEOUT.as_millis()
|
||||||
|
);
|
||||||
|
let out = tokio::time::timeout(
|
||||||
|
PROBE_TIMEOUT * 2,
|
||||||
|
exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "the viewer probe did not return".to_string())??;
|
||||||
|
parse_entry_probe(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the readiness probe's output into the path to load.
|
||||||
|
fn parse_entry_probe(out: &str) -> Result<String, String> {
|
||||||
|
let Some(idx) = out.find(PROBE_MARKER) else {
|
||||||
|
let trimmed = out.trim();
|
||||||
|
return Err(if trimmed.is_empty() {
|
||||||
|
"no response".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.lines().next_back().unwrap_or(trimmed).to_string()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let line = out[idx + PROBE_MARKER.len()..]
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let (status, location) = line.split_once(' ').unwrap_or((line, "/"));
|
||||||
|
match status {
|
||||||
|
"301" | "302" | "303" | "307" | "308" => {
|
||||||
|
// Only same-origin, absolute paths — the dashboard never sends
|
||||||
|
// anything else, and following an off-host redirect through the
|
||||||
|
// pane would be a nasty surprise.
|
||||||
|
if location.starts_with('/') {
|
||||||
|
Ok(location.to_string())
|
||||||
|
} else {
|
||||||
|
Ok("/".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"200" => Ok("/".to_string()),
|
||||||
|
other => Err(format!("viewer answered HTTP {}", other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pane's iframe URL: the viewer's own entry path with our session token
|
||||||
|
/// appended, on the host loopback port the gate is listening on.
|
||||||
|
fn build_url(host_port: u16, entry_path: &str, token: &str) -> String {
|
||||||
|
let sep = if entry_path.contains('?') { '&' } else { '?' };
|
||||||
|
format!(
|
||||||
|
"http://127.0.0.1:{}{}{}token={}",
|
||||||
|
host_port, entry_path, sep, token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-quote a path for `sh -c`. Paths from `require.resolve` never contain
|
||||||
|
/// quotes in practice, but this is a shell command line and the cost of being
|
||||||
|
/// sure is one line.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 256 bits of URL-safe randomness, matching `web_terminal`'s token shape.
|
||||||
|
fn generate_token() -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(app: &AppHandle, project_id: &str, status: &BrowserViewStatus) {
|
||||||
|
let _ = app.emit(
|
||||||
|
BROWSER_VIEW_EVENT,
|
||||||
|
serde_json::json!({ "project_id": project_id, "status": status }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_redirect_becomes_the_entry_path() {
|
||||||
|
let out = format!("\n{}302 /index.html?ws=abc123\n", PROBE_MARKER);
|
||||||
|
assert_eq!(parse_entry_probe(&out).unwrap(), "/index.html?ws=abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_200_entry_point_is_the_root() {
|
||||||
|
let out = format!("\n{}200 /\n", PROBE_MARKER);
|
||||||
|
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_off_host_redirect_is_not_followed() {
|
||||||
|
let out = format!("\n{}302 https://evil.example/\n", PROBE_MARKER);
|
||||||
|
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_connection_is_an_error_the_poller_can_retry() {
|
||||||
|
// Verified shape: node writes this to stderr with no trailing newline.
|
||||||
|
let err = parse_entry_probe("connect ECONNREFUSED 127.0.0.1:39321").unwrap_err();
|
||||||
|
assert!(err.contains("ECONNREFUSED"), "{}", err);
|
||||||
|
assert_eq!(parse_entry_probe("").unwrap_err(), "no response");
|
||||||
|
assert!(parse_entry_probe("timed out waiting for the viewer")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("timed out"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unexpected_status_is_surfaced_rather_than_loaded() {
|
||||||
|
let out = format!("\n{}500 /\n", PROBE_MARKER);
|
||||||
|
assert!(parse_entry_probe(&out).unwrap_err().contains("500"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pane_url_is_loopback_and_carries_the_token() {
|
||||||
|
let url = build_url(47820, "/index.html?ws=abc", "TOKEN");
|
||||||
|
assert_eq!(url, "http://127.0.0.1:47820/index.html?ws=abc&token=TOKEN");
|
||||||
|
assert!(url.starts_with("http://127.0.0.1:"));
|
||||||
|
|
||||||
|
// A viewer that doesn't redirect gets a `?`, not a stray `&`.
|
||||||
|
assert_eq!(
|
||||||
|
build_url(47821, "/", "T"),
|
||||||
|
"http://127.0.0.1:47821/?token=T"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_are_unique_and_url_safe() {
|
||||||
|
let a = generate_token();
|
||||||
|
let b = generate_token();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_eq!(a.len(), 43); // 32 bytes, base64url, unpadded
|
||||||
|
assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_quoting_survives_a_hostile_path() {
|
||||||
|
assert_eq!(shell_quote("/a/b/cli.js"), "'/a/b/cli.js'");
|
||||||
|
assert_eq!(
|
||||||
|
shell_quote("/a/'; rm -rf /; '"),
|
||||||
|
r#"'/a/'\''; rm -rf /; '\'''"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_singleton_clash_is_named_rather_than_left_as_a_dead_port() {
|
||||||
|
let msg = explain_start_failure(
|
||||||
|
"did not start listening on container port 39321 within 30s",
|
||||||
|
"Dashboard is running pid=1823\n",
|
||||||
|
);
|
||||||
|
assert!(msg.contains("show --kill"), "{}", msg);
|
||||||
|
assert!(msg.contains("pid=1823"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_start_failure_keeps_the_error_and_any_log() {
|
||||||
|
assert_eq!(explain_start_failure("boom", " "), "boom");
|
||||||
|
let msg = explain_start_failure("boom", "EADDRINUSE 39321");
|
||||||
|
assert!(msg.starts_with("boom"), "{}", msg);
|
||||||
|
assert!(msg.contains("EADDRINUSE 39321"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_viewer_port_range_is_bounded() {
|
||||||
|
assert_eq!(VIEWER_PORTS.clone().count(), 8);
|
||||||
|
// The auth bridge refuses to mirror exactly this range; if they ever
|
||||||
|
// drifted apart the pane would gain an ungated second front door.
|
||||||
|
assert_eq!(VIEWER_PORTS, crate::auth_bridge::RESERVED_CONTAINER_PORTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_off_status_says_nothing_is_running() {
|
||||||
|
let s = BrowserViewStatus::off(true);
|
||||||
|
assert!(s.enabled);
|
||||||
|
assert_eq!(s.state, BrowserViewState::Off);
|
||||||
|
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();
|
||||||
|
d.node_version = Some("22.11.0".to_string());
|
||||||
|
let s = BrowserViewStatus::unavailable(true, d, "install it".to_string());
|
||||||
|
assert_eq!(s.state, BrowserViewState::Unavailable);
|
||||||
|
assert_eq!(s.message.as_deref(), Some("install it"));
|
||||||
|
assert!(s.detection.is_some());
|
||||||
|
assert!(s.url.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! Open a page in the container's browser, and resize it while it runs.
|
||||||
|
//!
|
||||||
|
//! The pane [watches](super) browsers something else published. This opens one:
|
||||||
|
//! the user hands it a URL, it launches a browser inside the container,
|
||||||
|
//! publishes it with `browser.bind()` so the pane picks it up, and holds the
|
||||||
|
//! handle so the page can be navigated and **resized** afterwards.
|
||||||
|
//!
|
||||||
|
//! ## Why the handle has to be held
|
||||||
|
//!
|
||||||
|
//! Verified against a real bound browser: a second client cannot join one.
|
||||||
|
//! `chromium.connect()` against the published endpoint times out in every URL
|
||||||
|
//! form — the descriptor's socket speaks the dashboard's own transport, not the
|
||||||
|
//! public connect protocol. So whoever launches the browser is the only process
|
||||||
|
//! that can ever drive it. That is the whole reason this helper is a resident
|
||||||
|
//! process rather than a one-shot `node -e` that exits.
|
||||||
|
//!
|
||||||
|
//! It also draws the line for the feature: pages *this* opens can be resized
|
||||||
|
//! live; a browser `@playwright/mcp` launched can only be watched, and its size
|
||||||
|
//! is whatever `--viewport-size` it was given.
|
||||||
|
//!
|
||||||
|
//! ## Control channel
|
||||||
|
//!
|
||||||
|
//! A JSON file in `/tmp`, polled by the helper. No port, no second listener, no
|
||||||
|
//! addition to the proxy's attack surface — and it composes with the one exec
|
||||||
|
//! path this codebase already has. Writes go through `node -e` rather than
|
||||||
|
//! shell redirection so a URL never touches a shell.
|
||||||
|
//!
|
||||||
|
//! ## Viewport, and why it is the interesting part
|
||||||
|
//!
|
||||||
|
//! `page.setViewportSize()` genuinely reflows: measured on a page carrying a
|
||||||
|
//! `@media (max-width: 900px)` rule, the rule fires at 800×600 and clears at
|
||||||
|
//! 1440×900. Resizing the *window* the pane lives in does nothing of the sort —
|
||||||
|
//! the viewer is a CDP screencast, so a bigger window is the same pixels drawn
|
||||||
|
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::commands::project_commands::emit_progress;
|
||||||
|
use crate::docker::exec::exec_oneshot_as;
|
||||||
|
|
||||||
|
use super::detect::PlaywrightDetection;
|
||||||
|
|
||||||
|
/// Control file the helper polls, and the state file it writes back.
|
||||||
|
const CONTROL_PATH: &str = "/tmp/triple-c-page-control.json";
|
||||||
|
const STATE_PATH: &str = "/tmp/triple-c-page-state.json";
|
||||||
|
/// Where the detached helper's own output goes, so a failed start has a trail.
|
||||||
|
const HELPER_LOG: &str = "/tmp/triple-c-page.log";
|
||||||
|
|
||||||
|
/// How long to wait for the helper to report that the page is up.
|
||||||
|
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||||
|
/// Navigating a browser that is already up. One page load, not a cold start.
|
||||||
|
const REUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35);
|
||||||
|
const READY_POLL: std::time::Duration = std::time::Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// A viewport, in CSS pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Viewport {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
/// Clamped to something a browser will accept. A window dragged to nothing
|
||||||
|
/// must not ask Chromium for a zero-width page.
|
||||||
|
pub fn sane(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
width: width.clamp(200, 7680),
|
||||||
|
height: height.clamp(200, 4320),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the helper reports about itself.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct PageState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ready: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewport: Option<Viewport>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `url` in a freshly launched, bound browser.
|
||||||
|
///
|
||||||
|
/// Replaces any page this opened before: one helper per container, because the
|
||||||
|
/// pane shows one browser and a second would just compete for the pane.
|
||||||
|
pub async fn open(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
container_id: &str,
|
||||||
|
detection: &PlaywrightDetection,
|
||||||
|
url: &str,
|
||||||
|
viewport: Viewport,
|
||||||
|
) -> Result<PageState, String> {
|
||||||
|
let core = detection.playwright_path.as_deref().ok_or_else(|| {
|
||||||
|
"Playwright isn't installed in this container — set it up from the Browser tab first."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
// The directory of the resolved manifest is what `require()` wants.
|
||||||
|
let core_dir = core.trim_end_matches("/package.json");
|
||||||
|
|
||||||
|
// The executable is passed explicitly rather than left to Playwright's
|
||||||
|
// revision lookup: a container can hold browsers a given copy will not
|
||||||
|
// launch (see `detect::revision_skew`), and this is the one place we know
|
||||||
|
// which binary is actually on disk.
|
||||||
|
let executable = detection
|
||||||
|
.chromium_executable
|
||||||
|
.as_deref()
|
||||||
|
.filter(|_| detection.chromium_executable_exists);
|
||||||
|
|
||||||
|
// Reuse a helper that is already up. Relaunching would throw away the
|
||||||
|
// browser's cookies and storage — which for the auth case means signing in
|
||||||
|
// again to reach the second page, having just signed in on the first.
|
||||||
|
if state(container_id).await.ready {
|
||||||
|
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||||
|
set_viewport(container_id, viewport).await?;
|
||||||
|
navigate(container_id, url).await?;
|
||||||
|
if let Some(state) = wait_for_url(container_id, url).await {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
// It stopped answering; fall through and start a fresh one.
|
||||||
|
}
|
||||||
|
|
||||||
|
close(container_id).await;
|
||||||
|
// Cold start: a browser launch plus a page load, which is the several
|
||||||
|
// seconds the user would otherwise spend wondering whether the click
|
||||||
|
// registered.
|
||||||
|
emit_progress(app, project_id, "Launching a browser in the container…");
|
||||||
|
|
||||||
|
let config = serde_json::json!({
|
||||||
|
"core": core_dir,
|
||||||
|
"executable": executable,
|
||||||
|
"url": url,
|
||||||
|
"viewport": viewport,
|
||||||
|
"control": CONTROL_PATH,
|
||||||
|
"state": STATE_PATH,
|
||||||
|
});
|
||||||
|
let script = format!("const CFG={};{}", config, HELPER);
|
||||||
|
|
||||||
|
// Detached, for the same reason the viewer is: the process has to outlive
|
||||||
|
// the exec that started it, or the page closes the moment we return.
|
||||||
|
let launcher = format!(
|
||||||
|
"cd /workspace 2>/dev/null || true; rm -f {} {}; nohup node -e {} >{} 2>&1 &",
|
||||||
|
STATE_PATH,
|
||||||
|
CONTROL_PATH,
|
||||||
|
shell_quote(&script),
|
||||||
|
HELPER_LOG
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||||
|
|
||||||
|
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||||
|
wait_until_ready(container_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||||
|
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||||
|
write_control(
|
||||||
|
container_id,
|
||||||
|
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate the open page without relaunching the browser.
|
||||||
|
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||||
|
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||||
|
/// normal case, and the caller is usually about to start one anyway.
|
||||||
|
pub async fn close(container_id: &str) {
|
||||||
|
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state, or a default when no helper has ever run here.
|
||||||
|
pub async fn state(container_id: &str) -> PageState {
|
||||||
|
let script = format!(
|
||||||
|
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||||
|
STATE_PATH
|
||||||
|
);
|
||||||
|
let Ok((out, _)) = exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return PageState::default();
|
||||||
|
};
|
||||||
|
serde_json::from_str(out.trim()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||||
|
/// is never interpreted by `sh`.
|
||||||
|
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||||
|
let script = format!(
|
||||||
|
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||||
|
CONTROL_PATH
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||||
|
///
|
||||||
|
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||||
|
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||||
|
/// a fresh helper rather than reporting a page that isn't there.
|
||||||
|
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||||
|
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if state.ready && state.url.as_deref() == Some(url) {
|
||||||
|
return Some(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the state file until the helper says the page is up, or says why not.
|
||||||
|
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||||
|
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if let Some(error) = state.error.clone() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if state.ready {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"The browser didn't come up within {}s. Its log is at {} inside the container.",
|
||||||
|
READY_TIMEOUT.as_secs(),
|
||||||
|
HELPER_LOG
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-quote for `sh`, the same way [`super`] does for the viewer's paths.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resident helper, appended to a `const CFG={…};` prelude.
|
||||||
|
///
|
||||||
|
/// Deliberately one string passed as a single `argv` element — no shell parsing
|
||||||
|
/// of any part of it, exactly like `detect`'s probe. It launches, binds, and
|
||||||
|
/// then polls the control file; every failure path writes the state file, so a
|
||||||
|
/// helper that dies during startup is reported rather than waited out.
|
||||||
|
const HELPER: &str = concat!(
|
||||||
|
r#"const fs=require('fs');"#,
|
||||||
|
r#"const {chromium}=require(CFG.core);"#,
|
||||||
|
r#"const write=(o)=>{try{fs.writeFileSync(CFG.state,JSON.stringify(o));}catch(e){}};"#,
|
||||||
|
r#"const fail=(e)=>{write({ready:false,error:String(e&&e.message||e)});process.exit(1);};"#,
|
||||||
|
r#"process.on('unhandledRejection',fail);"#,
|
||||||
|
r#"(async()=>{"#,
|
||||||
|
// `chromiumSandbox:false` because the container has no user namespaces to
|
||||||
|
// give Chromium; headless because there is no display, which is also the
|
||||||
|
// only mode the dashboard can screencast anyway.
|
||||||
|
r#"const opts={headless:true,chromiumSandbox:false};"#,
|
||||||
|
r#"if(CFG.executable)opts.executablePath=CFG.executable;"#,
|
||||||
|
r#"const browser=await chromium.launch(opts);"#,
|
||||||
|
r#"const ctx=await browser.newContext({viewport:CFG.viewport});"#,
|
||||||
|
r#"const page=await ctx.newPage();"#,
|
||||||
|
// Bind before navigating: the pane should show the page loading rather than
|
||||||
|
// appearing once it is done.
|
||||||
|
r#"await browser.bind('claude',{metadata:{source:'triple-c'}});"#,
|
||||||
|
r#"let current=CFG.url,viewport=CFG.viewport;"#,
|
||||||
|
r#"const report=()=>write({ready:true,url:current,viewport});"#,
|
||||||
|
r#"try{await page.goto(CFG.url,{waitUntil:'domcontentloaded',timeout:30000});}catch(e){}"#,
|
||||||
|
r#"report();"#,
|
||||||
|
// The control loop. A poll, not a watcher: `fs.watch` misses writes on some
|
||||||
|
// filesystems and this costs nothing at 4 Hz.
|
||||||
|
r#"setInterval(async()=>{let c;try{c=JSON.parse(fs.readFileSync(CFG.control,'utf8'));}catch(e){return;}"#,
|
||||||
|
r#"try{fs.unlinkSync(CFG.control);}catch(e){}"#,
|
||||||
|
r#"if(c.close){await browser.close().catch(()=>{});write({ready:false});process.exit(0);}"#,
|
||||||
|
r#"if(c.viewport){viewport=c.viewport;await page.setViewportSize(c.viewport).catch(()=>{});}"#,
|
||||||
|
r#"if(c.url&&c.url!==current){current=c.url;await page.goto(c.url,{waitUntil:'domcontentloaded',timeout:30000}).catch(()=>{});}"#,
|
||||||
|
r#"report();},250);"#,
|
||||||
|
// A browser that dies (crash, or the user closing the last page) must not
|
||||||
|
// leave a helper claiming a live page.
|
||||||
|
r#"browser.on('disconnected',()=>{write({ready:false});process.exit(0);});"#,
|
||||||
|
r#"})().catch(fail);"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_is_one_argv_element_with_no_shell_hazards() {
|
||||||
|
// Same rule as the detect probe: it is passed as a single argument, so
|
||||||
|
// it must contain neither a newline nor a single quote that would end
|
||||||
|
// the quoting `open` wraps it in.
|
||||||
|
assert!(!HELPER.contains('\n'), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("chromium.launch"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_binds_so_the_pane_can_see_the_page() {
|
||||||
|
// Without this the page opens and the pane shows nothing — the whole
|
||||||
|
// feature hinges on the browser being published.
|
||||||
|
assert!(HELPER.contains("browser.bind('claude'"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_reports_startup_failures_instead_of_hanging() {
|
||||||
|
// `wait_until_ready` polls the state file; a helper that dies silently
|
||||||
|
// would turn every failure into a 45-second timeout.
|
||||||
|
assert!(HELPER.contains("unhandledRejection"), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("error:String"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_viewport_is_clamped_to_something_a_browser_accepts() {
|
||||||
|
assert_eq!(Viewport::sane(0, 0), Viewport { width: 200, height: 200 });
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(99_999, 99_999),
|
||||||
|
Viewport { width: 7680, height: 4320 }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(1440, 900),
|
||||||
|
Viewport { width: 1440, height: 900 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_is_never_parsed_by_a_shell() {
|
||||||
|
// The launcher runs through `sh -c`, so the script is quoted with the
|
||||||
|
// POSIX close-escape-reopen form: the embedded quote becomes `'\''`,
|
||||||
|
// which leaves the `;rm` inside the string rather than starting a new
|
||||||
|
// command. (A naive "the output must not contain ';rm'" check fails
|
||||||
|
// here and would be wrong — that substring is *inside* the quoting.)
|
||||||
|
assert_eq!(
|
||||||
|
shell_quote("http://x/?a=1&b=2';rm -rf /"),
|
||||||
|
r"'http://x/?a=1&b=2'\'';rm -rf /'"
|
||||||
|
);
|
||||||
|
// The control channel doesn't go near a shell at all: the JSON travels
|
||||||
|
// as an argv element to `node`.
|
||||||
|
assert!(!HELPER.contains("exec("), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_defaults_to_not_ready_rather_than_failing() {
|
||||||
|
// An empty/absent state file is the normal case before anything runs.
|
||||||
|
let s: PageState = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(!s.ready);
|
||||||
|
assert!(s.error.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! The browser view in a window of its own.
|
||||||
|
//!
|
||||||
|
//! Watching a browser and working in a terminal are the same task done at the
|
||||||
|
//! same time, and a tab can only be one of them. So the pane can be detached
|
||||||
|
//! into a second OS window — put on the other monitor, or pinned on top of
|
||||||
|
//! whatever else is in front.
|
||||||
|
//!
|
||||||
|
//! ## Why this is a native window and not a second iframe
|
||||||
|
//!
|
||||||
|
//! The window loads the *same* token-bearing loopback URL the pane's iframe
|
||||||
|
//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level
|
||||||
|
//! document. That has two consequences worth stating:
|
||||||
|
//!
|
||||||
|
//! - It is a **remote-origin** webview. No capability lists this window, so it
|
||||||
|
//! has no IPC surface at all — `invoke` is not reachable from it, which is
|
||||||
|
//! exactly right for a page served out of a container. Do not add one.
|
||||||
|
//! - The app CSP does not apply, and does not need to: `frame-src` exists to
|
||||||
|
//! constrain what the *app's* document may embed, and this is not embedded.
|
||||||
|
//! The port is still confined to [`crate::browser_view::proxy`]'s range and
|
||||||
|
//! still gated by the session token, which is what actually protects it.
|
||||||
|
//!
|
||||||
|
//! ## Lifetime
|
||||||
|
//!
|
||||||
|
//! The window is owned by the session, not by the user's patience: when a view
|
||||||
|
//! stops — the user pressed Stop, the container went away, the viewer died —
|
||||||
|
//! the supervisor's teardown calls [`close`], because a window left showing a
|
||||||
|
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||||
|
//! window leaves the view running, and the pane takes it back into the tab.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
|
/// Emitted when a pop-out opens or closes. Payload: [`PopoutState`] plus the
|
||||||
|
/// project id.
|
||||||
|
///
|
||||||
|
/// The window can close without the app asking it to — the user hits its X, or
|
||||||
|
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||||
|
/// about everything else here, by listening.
|
||||||
|
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
||||||
|
|
||||||
|
/// What the pane needs to render its pop-out controls.
|
||||||
|
///
|
||||||
|
/// Both fields are read from the window itself rather than remembered on either
|
||||||
|
/// side: the pane is unmounted whenever another Project Home sub-tab is
|
||||||
|
/// selected, so anything it merely *remembers* about the window is gone by the
|
||||||
|
/// time the user comes back, while the window is still there.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
pub struct PopoutState {
|
||||||
|
pub open: bool,
|
||||||
|
pub always_on_top: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopoutState {
|
||||||
|
const CLOSED: Self = Self {
|
||||||
|
open: false,
|
||||||
|
always_on_top: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
|
||||||
|
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||||
|
/// cannot produce a label Tauri rejects at build time.
|
||||||
|
pub fn window_label(project_id: &str) -> String {
|
||||||
|
let id: String = project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
format!("browser-view-{}", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the pop-out, or raise it if it is already open.
|
||||||
|
///
|
||||||
|
/// `url` is the live session's URL; the caller has already established that the
|
||||||
|
/// view is running, because there is nothing to show otherwise.
|
||||||
|
pub fn open(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
project_name: &str,
|
||||||
|
url: &str,
|
||||||
|
always_on_top: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let label = window_label(project_id);
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
// Asking twice means "I can't see it", not "open another".
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
let _ = window.set_always_on_top(always_on_top);
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("The browser view's address is not a URL: {}", e))?;
|
||||||
|
|
||||||
|
let project_id_owned = project_id.to_string();
|
||||||
|
let app_for_event = app.clone();
|
||||||
|
|
||||||
|
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
|
||||||
|
.title(format!("{} — browser", project_name))
|
||||||
|
.inner_size(1100.0, 820.0)
|
||||||
|
.min_inner_size(480.0, 360.0)
|
||||||
|
.always_on_top(always_on_top)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||||
|
|
||||||
|
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||||
|
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||||
|
// see `set_match_window`.
|
||||||
|
window.on_window_event(move |event| match event {
|
||||||
|
WindowEvent::Destroyed => {
|
||||||
|
set_match_window(&project_id_owned, false);
|
||||||
|
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
on_resized(&app_for_event, &project_id_owned, size.width, size.height);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("Browser view: popped out for project {}", project_id);
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the pop-out if there is one. Safe to call when there isn't.
|
||||||
|
///
|
||||||
|
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||||
|
/// window-event handler treats that as a request to quit for the main window.
|
||||||
|
/// Nothing here should ever be able to be mistaken for that.
|
||||||
|
///
|
||||||
|
/// A failure is **returned, not logged and forgotten**. The pane puts its
|
||||||
|
/// iframe back the moment it believes the window is gone, so reporting a close
|
||||||
|
/// that did not happen is how you end up with two viewers driving one browser —
|
||||||
|
/// the exact state the iframe is dropped to prevent.
|
||||||
|
pub fn close(app: &AppHandle, project_id: &str) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
||||||
|
window.destroy().map_err(|e| {
|
||||||
|
log::warn!(
|
||||||
|
"Browser view: could not close the pop-out for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
format!("Could not close the browser window: {}", e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
// `Destroyed` covers the normal path; a window that was already gone still
|
||||||
|
// owes the pane an answer.
|
||||||
|
emit(app, project_id, PopoutState::CLOSED);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the window exists and how it is stacked, read from the window.
|
||||||
|
pub fn state(app: &AppHandle, project_id: &str) -> PopoutState {
|
||||||
|
match app.get_webview_window(&window_label(project_id)) {
|
||||||
|
Some(window) => PopoutState {
|
||||||
|
open: true,
|
||||||
|
// A window that cannot answer is not a reason to fail the call; the
|
||||||
|
// pin is a preference, and "not pinned" is the safe reading.
|
||||||
|
always_on_top: window.is_always_on_top().unwrap_or(false),
|
||||||
|
},
|
||||||
|
None => PopoutState::CLOSED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||||
|
pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> {
|
||||||
|
let Some(window) = app.get_webview_window(&window_label(project_id)) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.set_always_on_top(on_top)
|
||||||
|
.map_err(|e| format!("Could not change the window's stacking: {}", e))?;
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Match-window mode
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Projects whose pop-out is driving the page's viewport, and the generation of
|
||||||
|
/// the latest resize for each — the debounce is "did anything else arrive while
|
||||||
|
/// I slept?", which needs no timer to cancel.
|
||||||
|
static MATCH_WINDOW: OnceLock<Mutex<HashMap<String, (bool, u64)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// How long the window has to stop moving before the page is resized.
|
||||||
|
///
|
||||||
|
/// A drag emits `Resized` continuously; each one costs a container exec, and
|
||||||
|
/// Chromium relayouts the page. Settling first turns a drag into one resize.
|
||||||
|
const RESIZE_SETTLE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
fn match_window_map() -> &'static Mutex<HashMap<String, (bool, u64)>> {
|
||||||
|
MATCH_WINDOW.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn match-window mode on or off for a project.
|
||||||
|
///
|
||||||
|
/// Only ever affects a page **Triple-C opened** — a bound browser cannot be
|
||||||
|
/// joined by a second client, so a page `@playwright/mcp` launched keeps
|
||||||
|
/// whatever viewport it was given. See [`super::page`].
|
||||||
|
pub fn set_match_window(project_id: &str, enabled: bool) {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let entry = map.entry(project_id.to_string()).or_insert((false, 0));
|
||||||
|
entry.0 = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_window(project_id: &str) -> bool {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(project_id)
|
||||||
|
.map(|(on, _)| *on)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pop-out's current inner size, for applying match-window immediately
|
||||||
|
/// rather than only on the next drag.
|
||||||
|
pub fn inner_size(app: &AppHandle, project_id: &str) -> Option<(u32, u32)> {
|
||||||
|
let window = app.get_webview_window(&window_label(project_id))?;
|
||||||
|
let size = window.inner_size().ok()?;
|
||||||
|
Some((size.width, size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounce a resize, then push the settled size into the page's viewport.
|
||||||
|
fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||||
|
let generation = {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let Some(entry) = map.get_mut(project_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !entry.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.1 += 1;
|
||||||
|
entry.1
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = app.clone();
|
||||||
|
let project_id = project_id.to_string();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(RESIZE_SETTLE).await;
|
||||||
|
// Superseded by a later resize: that one will do the work.
|
||||||
|
{
|
||||||
|
let map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match map.get(&project_id) {
|
||||||
|
Some((true, latest)) if *latest == generation => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = app.state::<crate::AppState>();
|
||||||
|
let Some(container_id) = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.and_then(|p| p.container_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = super::page::set_viewport(
|
||||||
|
&container_id,
|
||||||
|
super::page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!("Browser view: could not match the page to the window: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||||
|
let _ = app.emit(
|
||||||
|
POPOUT_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"open": state.open,
|
||||||
|
"always_on_top": state.always_on_top,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_are_derived_from_the_project_and_are_tauri_safe() {
|
||||||
|
assert_eq!(
|
||||||
|
window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"),
|
||||||
|
"browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"
|
||||||
|
);
|
||||||
|
assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_projects_get_distinct_windows() {
|
||||||
|
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_window_is_off_until_asked_for_and_is_per_project() {
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
set_match_window("mw-a", true);
|
||||||
|
assert!(match_window("mw-a"));
|
||||||
|
// Another project's window must not start driving its page too.
|
||||||
|
assert!(!match_window("mw-b"));
|
||||||
|
set_match_window("mw-a", false);
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resize_supersedes_the_one_before_it() {
|
||||||
|
// The debounce is a generation counter, not a cancellable timer: only
|
||||||
|
// the newest resize of a drag survives to touch the container.
|
||||||
|
set_match_window("mw-gen", true);
|
||||||
|
let read = || {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get("mw-gen")
|
||||||
|
.map(|(_, g)| *g)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let before = read();
|
||||||
|
{
|
||||||
|
let mut map = match_window_map().lock().unwrap();
|
||||||
|
let entry = map.get_mut("mw-gen").unwrap();
|
||||||
|
entry.1 += 1;
|
||||||
|
}
|
||||||
|
assert!(read() > before);
|
||||||
|
set_match_window("mw-gen", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
//! The host-side, token-gated front door for one project's Playwright viewer.
|
||||||
|
//!
|
||||||
|
//! ## Why this is not `PortForward` on its own
|
||||||
|
//!
|
||||||
|
//! [`crate::auth_bridge::tunnel::PortForward`] mirrors a container loopback port
|
||||||
|
//! onto the *same* host loopback port with **no authentication at all**. That is
|
||||||
|
//! the right trade for the auth bridge — the things it exposes are short-lived
|
||||||
|
//! OAuth callback listeners whose whole purpose is to receive one unauthenticated
|
||||||
|
//! request — but it is the wrong trade here. The Playwright viewer is full mouse
|
||||||
|
//! and keyboard control of a browser running inside a container that has
|
||||||
|
//! passwordless sudo and, very often, the host's Docker socket bind-mounted. A
|
||||||
|
//! bare loopback port is reachable by:
|
||||||
|
//!
|
||||||
|
//! * any other local user on a multi-user host, and
|
||||||
|
//! * **any web page the user happens to have open**, via localhost port scanning
|
||||||
|
//! or DNS rebinding.
|
||||||
|
//!
|
||||||
|
//! So this module keeps the tunnel half of the auth bridge (a per-connection
|
||||||
|
//! `socat` exec through the Docker API — see
|
||||||
|
//! [`crate::auth_bridge::tunnel::tunnel_connection_with_prelude`]) and replaces
|
||||||
|
//! the listener half with one that authenticates before a single byte reaches
|
||||||
|
//! the container. There is therefore exactly **one** host-bound socket per
|
||||||
|
//! session, and it is gated.
|
||||||
|
//!
|
||||||
|
//! ## The gate
|
||||||
|
//!
|
||||||
|
//! Gating happens on the first HTTP request head of every accepted TCP
|
||||||
|
//! connection, before anything is forwarded. To get a connection through you
|
||||||
|
//! must satisfy all of:
|
||||||
|
//!
|
||||||
|
//! 1. `Host` is `127.0.0.1:<port>` or `localhost:<port>` — this is the
|
||||||
|
//! anti-DNS-rebinding check. A page on `evil.com` that rebinds its name to
|
||||||
|
//! 127.0.0.1 still sends `Host: evil.com`.
|
||||||
|
//! 2. Either
|
||||||
|
//! * the request carries the session token (in `?token=`, in a `Cookie`, or
|
||||||
|
//! in the query of a same-origin `Referer`), **or**
|
||||||
|
//! * `Origin` / `Referer` is exactly this proxy's own origin — i.e. the
|
||||||
|
//! request was issued by a document that we already served, which itself
|
||||||
|
//! had to present the token. This is what lets the viewer's own
|
||||||
|
//! sub-resource and WebSocket requests through: a browser will not let a
|
||||||
|
//! hostile page forge either header, and requests that carry neither (a
|
||||||
|
//! cross-site `<script src>` or a top-level navigation) are rejected.
|
||||||
|
//!
|
||||||
|
//! Once the first head passes, the rest of the connection is spliced verbatim,
|
||||||
|
//! so HTTP/1.1 keep-alive, the WebSocket upgrade and the CDP screencast frames
|
||||||
|
//! all pass through untouched and protocol-agnostically. Riding an existing
|
||||||
|
//! connection is not an escalation: opening one required the token.
|
||||||
|
//!
|
||||||
|
//! ## Port allocation and the CSP
|
||||||
|
//!
|
||||||
|
//! Host ports come from the small fixed range [`PROXY_PORTS`]. That is
|
||||||
|
//! deliberate: `tauri.conf.json`'s `frame-src` has to name every origin the pane
|
||||||
|
//! may embed, and CSP has no port wildcards short of `http://127.0.0.1:*`.
|
||||||
|
//! Allocating from a bounded, known range keeps that directive an exact
|
||||||
|
//! enumeration instead of "any localhost port".
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
use crate::auth_bridge::proc_net::PortFamily;
|
||||||
|
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
|
||||||
|
|
||||||
|
/// Host loopback ports the pane may be served on, and therefore the exact set of
|
||||||
|
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
|
||||||
|
/// port here without adding it to `tauri.conf.json` produces a pane that is
|
||||||
|
/// silently blocked by CSP.
|
||||||
|
pub const PROXY_PORTS: std::ops::RangeInclusive<u16> = 47820..=47827;
|
||||||
|
|
||||||
|
/// Ceiling on the request head we will buffer before deciding. Real heads are
|
||||||
|
/// well under 8 KiB; anything larger is either broken or hostile.
|
||||||
|
const MAX_HEAD: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// How long a freshly accepted connection has to produce a complete request
|
||||||
|
/// head. Prevents a slowloris from pinning accept-loop tasks.
|
||||||
|
const HEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
const REFUSAL_BODY: &str = concat!(
|
||||||
|
"<!doctype html><meta charset=\"utf-8\">",
|
||||||
|
"<title>Not available</title>",
|
||||||
|
"<p>This Triple-C browser view is only reachable from the app that started it.</p>"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A bound, token-gated host listener in front of one container-side viewer.
|
||||||
|
///
|
||||||
|
/// The accept loop owns the [`TcpListener`] and the [`JoinSet`] of live
|
||||||
|
/// connections, so aborting the one task handle releases the port *and* tears
|
||||||
|
/// down everything under it. [`Drop`] does that as a backstop;
|
||||||
|
/// [`BrowserViewProxy::shutdown`] does it deterministically by also awaiting the
|
||||||
|
/// aborted task, so the port is provably free before the caller continues.
|
||||||
|
pub struct BrowserViewProxy {
|
||||||
|
pub port: u16,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BrowserViewProxy {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserViewProxy {
|
||||||
|
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
|
||||||
|
/// start gating connections into `container_id`'s `container_port`.
|
||||||
|
pub async fn bind(
|
||||||
|
container_id: String,
|
||||||
|
container_port: u16,
|
||||||
|
family: PortFamily,
|
||||||
|
token: String,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for port in PROXY_PORTS {
|
||||||
|
// SECURITY BOUNDARY: 127.0.0.1 ONLY, never 0.0.0.0. Unlike
|
||||||
|
// `web_terminal`, which binds a wildcard on purpose because remote
|
||||||
|
// access *is* its feature, this pane is remote control of a browser
|
||||||
|
// in a privileged container and must never leave the host. Do not
|
||||||
|
// "fix" a connectivity problem by widening this address.
|
||||||
|
match TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(listener) => {
|
||||||
|
let task = tokio::spawn(accept_loop(
|
||||||
|
listener,
|
||||||
|
container_id,
|
||||||
|
family.socat_target(container_port),
|
||||||
|
container_port,
|
||||||
|
token,
|
||||||
|
self_origins(port),
|
||||||
|
host_authorities(port),
|
||||||
|
));
|
||||||
|
log::info!("Browser view: proxy listening on 127.0.0.1:{}", port);
|
||||||
|
return Ok(Self { port, task });
|
||||||
|
}
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"No free host port in {}–{} for the browser view proxy ({}). \
|
||||||
|
Close another project's browser view and try again.",
|
||||||
|
PROXY_PORTS.start(),
|
||||||
|
PROXY_PORTS.end(),
|
||||||
|
last_err
|
||||||
|
.map(|e| e.to_string())
|
||||||
|
.unwrap_or_else(|| "range empty".to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting, release the host port and abort every live connection.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
log::info!("Browser view: proxy on 127.0.0.1:{} released", self.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The origins a request may legitimately claim to come from.
|
||||||
|
fn self_origins(port: u16) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
format!("http://127.0.0.1:{}", port),
|
||||||
|
format!("http://localhost:{}", port),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Host` values we will answer to. Anything else is a rebinding attempt.
|
||||||
|
fn host_authorities(port: u16) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
format!("127.0.0.1:{}", port),
|
||||||
|
format!("localhost:{}", port),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
container_id: String,
|
||||||
|
target: String,
|
||||||
|
container_port: u16,
|
||||||
|
token: String,
|
||||||
|
origins: Vec<String>,
|
||||||
|
authorities: Vec<String>,
|
||||||
|
) {
|
||||||
|
let mut conns: JoinSet<()> = JoinSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
r = listener.accept() => r,
|
||||||
|
// Reap finished connections so the set can't grow without bound.
|
||||||
|
// An empty set yields `None`, the pattern fails, and the branch is
|
||||||
|
// simply dropped from the select.
|
||||||
|
Some(_) = conns.join_next() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, _peer)) => {
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
conns.spawn(serve_connection(
|
||||||
|
stream,
|
||||||
|
container_id.clone(),
|
||||||
|
target.clone(),
|
||||||
|
container_port,
|
||||||
|
token.clone(),
|
||||||
|
origins.clone(),
|
||||||
|
authorities.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Browser view: accept failed: {} — stopping proxy listener", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn serve_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
container_id: String,
|
||||||
|
target: String,
|
||||||
|
container_port: u16,
|
||||||
|
token: String,
|
||||||
|
origins: Vec<String>,
|
||||||
|
authorities: Vec<String>,
|
||||||
|
) {
|
||||||
|
let (head, head_len) = match tokio::time::timeout(HEAD_TIMEOUT, read_head(&mut stream)).await {
|
||||||
|
Ok(Ok(head)) => head,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::debug!("Browser view: dropping connection: {}", e);
|
||||||
|
let _ = reject(&mut stream, 400, "Bad Request").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("Browser view: dropping connection: no request head within timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authorize against the head slice only — never the trailing body bytes.
|
||||||
|
let head_text = String::from_utf8_lossy(&head[..head_len]).into_owned();
|
||||||
|
let verdict = authorize(&head_text, &token, &origins, &authorities);
|
||||||
|
if verdict != Verdict::Allow {
|
||||||
|
log::warn!(
|
||||||
|
"Browser view: rejected a connection on the proxy for container port {} ({:?})",
|
||||||
|
container_port,
|
||||||
|
verdict
|
||||||
|
);
|
||||||
|
let _ = reject(&mut stream, 403, "Forbidden").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
|
||||||
|
// auth bridge uses, replaying the head we had to buffer to make the call.
|
||||||
|
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
|
||||||
|
/// Returns the bytes read so far and the index one past the head terminator.
|
||||||
|
///
|
||||||
|
/// Both halves matter. The caller must replay the **whole** buffer into the
|
||||||
|
/// tunnel — a client may pipeline body bytes into the same packet as the head —
|
||||||
|
/// but it must authorize against the **head only**. Returning just the buffer
|
||||||
|
/// is how a request body gets parsed as headers, which defeats the token gate
|
||||||
|
/// and the anti-rebinding check outright: a cross-site `fetch` with a
|
||||||
|
/// `text/plain` body of `a=x\r\nSec-Fetch-Site: same-origin\r\n` is not
|
||||||
|
/// preflighted, and the forged line wins the last-occurrence match below.
|
||||||
|
async fn read_head(stream: &mut TcpStream) -> Result<(Vec<u8>, usize), String> {
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = stream
|
||||||
|
.read(&mut chunk)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read failed: {}", e))?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("connection closed before a request head arrived".to_string());
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
if let Some(head_end) = find_head_end(&buf) {
|
||||||
|
return Ok((buf, head_end));
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_HEAD {
|
||||||
|
return Err(format!("request head exceeded {} bytes", MAX_HEAD));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index just past the blank line terminating the head, if it has arrived.
|
||||||
|
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||||
|
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||||
|
buf.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.map(|i| i + 4)
|
||||||
|
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject(stream: &mut TcpStream, code: u16, reason: &str) -> std::io::Result<()> {
|
||||||
|
let body = REFUSAL_BODY;
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {} {}\r\n\
|
||||||
|
Content-Type: text/html; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\n\
|
||||||
|
Cache-Control: no-store\r\n\
|
||||||
|
Connection: close\r\n\r\n{}",
|
||||||
|
code,
|
||||||
|
reason,
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.shutdown().await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The gate itself — pure, so it can be tested without sockets
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Verdict {
|
||||||
|
Allow,
|
||||||
|
/// No request line, or one we can't parse.
|
||||||
|
Malformed,
|
||||||
|
/// `Host` is not one of ours — a rebinding attempt, or a stray client.
|
||||||
|
BadHost,
|
||||||
|
/// Well-formed and addressed to us, but presented no token and no proof of
|
||||||
|
/// having come from a document we served.
|
||||||
|
Unauthenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the connection whose first request head this is may be
|
||||||
|
/// spliced into the container. See the module docs for the rules.
|
||||||
|
pub(crate) fn authorize(
|
||||||
|
head: &str,
|
||||||
|
token: &str,
|
||||||
|
self_origins: &[String],
|
||||||
|
host_authorities: &[String],
|
||||||
|
) -> Verdict {
|
||||||
|
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||||
|
|
||||||
|
let Some(request_line) = lines.next() else {
|
||||||
|
return Verdict::Malformed;
|
||||||
|
};
|
||||||
|
// "GET /path?query HTTP/1.1"
|
||||||
|
let mut parts = request_line.split(' ');
|
||||||
|
let (Some(_method), Some(request_target)) = (parts.next(), parts.next()) else {
|
||||||
|
return Verdict::Malformed;
|
||||||
|
};
|
||||||
|
if !request_target.starts_with('/') && !request_target.starts_with("http") {
|
||||||
|
// CONNECT and origin-form-violating targets are not something the
|
||||||
|
// viewer ever sends; refuse to be used as a forward proxy.
|
||||||
|
return Verdict::Malformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut origin = None;
|
||||||
|
let mut referer = None;
|
||||||
|
let mut cookie = None;
|
||||||
|
let mut fetch_site = None;
|
||||||
|
for line in lines {
|
||||||
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
// Duplicates of a security-relevant header are refused rather than
|
||||||
|
// resolved. Last-occurrence-wins is what turns any header-smuggling
|
||||||
|
// primitive into a full bypass, and no legitimate client sends two.
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"host" if host.is_some() => return Verdict::Malformed,
|
||||||
|
"origin" if origin.is_some() => return Verdict::Malformed,
|
||||||
|
"sec-fetch-site" if fetch_site.is_some() => return Verdict::Malformed,
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"origin" => origin = Some(value),
|
||||||
|
"referer" => referer = Some(value),
|
||||||
|
"cookie" => cookie = Some(value),
|
||||||
|
"sec-fetch-site" => fetch_site = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Anti-rebinding. A hostile page that points its own name at 127.0.0.1
|
||||||
|
// still sends its own name here.
|
||||||
|
match host {
|
||||||
|
Some(h) if host_authorities.iter().any(|a| a.eq_ignore_ascii_case(h)) => {}
|
||||||
|
_ => return Verdict::BadHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2a. An explicit token, from the request target, a cookie, or the query of
|
||||||
|
// the referring document's URL (same-origin requests send the full URL,
|
||||||
|
// query included, under the default referrer policy).
|
||||||
|
if query_token(request_target).is_some_and(|t| tokens_match(t, token))
|
||||||
|
|| cookie_token(cookie.unwrap_or("")).is_some_and(|t| tokens_match(t, token))
|
||||||
|
|| referer.and_then(query_token).is_some_and(|t| tokens_match(t, token))
|
||||||
|
{
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. …or proof that a document we already served issued this request. The
|
||||||
|
// viewer's WebSocket upgrade carries `Origin` and no `Referer`, and
|
||||||
|
// nothing in it is under our control, so this is the clause that makes
|
||||||
|
// the pane work at all. A browser will not let a hostile page forge
|
||||||
|
// either header; a request with neither (cross-site `<script src>`,
|
||||||
|
// top-level navigation, `curl`) falls through and is refused.
|
||||||
|
if origin.is_some_and(|o| origin_is_self(o, self_origins))
|
||||||
|
|| referer.is_some_and(|r| origin_is_self(r, self_origins))
|
||||||
|
// Fetch metadata says the same thing as `Origin`, and keeps saying it
|
||||||
|
// for the plain sub-resource loads that carry no `Origin` and whose
|
||||||
|
// `Referer` a `no-referrer` policy could strip. `Sec-Fetch-Site` is a
|
||||||
|
// forbidden header, so page script cannot set it either.
|
||||||
|
|| fetch_site.is_some_and(|s| s.eq_ignore_ascii_case("same-origin"))
|
||||||
|
{
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Verdict::Unauthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of a `token` query parameter in a request target or absolute URL.
|
||||||
|
fn query_token(target: &str) -> Option<&str> {
|
||||||
|
let query = target.split_once('?')?.1;
|
||||||
|
// Fragments never reach the wire in a request target, but a `Referer` can
|
||||||
|
// legally carry one on some clients.
|
||||||
|
let query = query.split('#').next().unwrap_or(query);
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let (k, v) = pair.split_once('=')?;
|
||||||
|
(k == "token").then_some(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of our session cookie in a `Cookie` header.
|
||||||
|
fn cookie_token(cookie_header: &str) -> Option<&str> {
|
||||||
|
cookie_header.split(';').find_map(|pair| {
|
||||||
|
let (k, v) = pair.split_once('=')?;
|
||||||
|
(k.trim() == COOKIE_NAME).then_some(v.trim())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the cookie the gate will accept a token in. Nothing sets it today —
|
||||||
|
/// the pane relies on the query parameter for the document and on `Origin` /
|
||||||
|
/// `Referer` for everything under it, because a webview iframe pointed at
|
||||||
|
/// 127.0.0.1 is a third-party context and WKWebView and WebKitGTK both drop
|
||||||
|
/// third-party cookies by default. It is accepted so that a future first-party
|
||||||
|
/// entry point (opening the pane in the user's own browser, say) needs no
|
||||||
|
/// change here.
|
||||||
|
const COOKIE_NAME: &str = "triple_c_browser_view";
|
||||||
|
|
||||||
|
/// Whether a URL (or bare origin) has exactly one of our own origins.
|
||||||
|
fn origin_is_self(value: &str, self_origins: &[String]) -> bool {
|
||||||
|
// Compare scheme://host:port only; a Referer carries a path as well.
|
||||||
|
let origin = match value.split_once("://") {
|
||||||
|
Some((scheme, rest)) => {
|
||||||
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
|
||||||
|
format!("{}://{}", scheme, authority)
|
||||||
|
}
|
||||||
|
None => value.to_string(),
|
||||||
|
};
|
||||||
|
self_origins.iter().any(|o| o.eq_ignore_ascii_case(&origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length-independent-ish equality. A timing oracle over a loopback socket is
|
||||||
|
/// not a realistic attack, but comparing in constant time costs nothing and
|
||||||
|
/// keeps the primitive honest.
|
||||||
|
fn tokens_match(candidate: &str, expected: &str) -> bool {
|
||||||
|
let a = candidate.as_bytes();
|
||||||
|
let b = expected.as_bytes();
|
||||||
|
let mut diff = (a.len() ^ b.len()) as u8;
|
||||||
|
for i in 0..a.len().max(b.len()) {
|
||||||
|
let x = a.get(i).copied().unwrap_or(0);
|
||||||
|
let y = b.get(i).copied().unwrap_or(0);
|
||||||
|
diff |= x ^ y;
|
||||||
|
}
|
||||||
|
diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
/// The body of a cross-site POST must never be parsed as headers.
|
||||||
|
///
|
||||||
|
/// `text/plain` is CORS-safelisted, so `fetch(..., {mode:'no-cors'})` sends
|
||||||
|
/// this with no preflight. Before the head was truncated at its terminator,
|
||||||
|
/// the forged trailing line won the last-occurrence match and the gate
|
||||||
|
/// returned Allow — an unauthenticated takeover of the container's browser
|
||||||
|
/// from any page the user happened to visit.
|
||||||
|
#[test]
|
||||||
|
fn body_bytes_are_not_parsed_as_headers() {
|
||||||
|
// Deliberately no Sec-Fetch-Site in the head, so the duplicate-header
|
||||||
|
// guard is not what saves us — this isolates truncation on its own.
|
||||||
|
let raw = concat!(
|
||||||
|
"POST / HTTP/1.1\r\n",
|
||||||
|
"Host: 127.0.0.1:47820\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"a=x\r\nSec-Fetch-Site: same-origin\r\n",
|
||||||
|
);
|
||||||
|
let head_end = find_head_end(raw.as_bytes()).expect("terminator present");
|
||||||
|
let head = &raw[..head_end];
|
||||||
|
let verdict = authorize(
|
||||||
|
head,
|
||||||
|
"tok",
|
||||||
|
&["http://127.0.0.1:47820".to_string()],
|
||||||
|
&["127.0.0.1:47820".to_string()],
|
||||||
|
);
|
||||||
|
assert_ne!(verdict, Verdict::Allow, "body line must not authorize");
|
||||||
|
|
||||||
|
// And the whole buffer — the pre-fix input — would have been allowed,
|
||||||
|
// which is what makes the truncation load-bearing rather than cosmetic.
|
||||||
|
assert_eq!(
|
||||||
|
authorize(
|
||||||
|
raw,
|
||||||
|
"tok",
|
||||||
|
&["http://127.0.0.1:47820".to_string()],
|
||||||
|
&["127.0.0.1:47820".to_string()]
|
||||||
|
),
|
||||||
|
Verdict::Allow,
|
||||||
|
"guard test: the untruncated buffer is exactly the bypass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A smuggled duplicate must be refused, not resolved last-wins.
|
||||||
|
#[test]
|
||||||
|
fn duplicate_security_headers_are_refused() {
|
||||||
|
for dup in [
|
||||||
|
"Host: 127.0.0.1:47820",
|
||||||
|
"Origin: http://127.0.0.1:47820",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
] {
|
||||||
|
let raw = format!(
|
||||||
|
"GET / HTTP/1.1\r\nHost: evil.example:47820\r\nOrigin: http://evil.example\r\nSec-Fetch-Site: cross-site\r\n{}\r\n\r\n",
|
||||||
|
dup
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authorize(
|
||||||
|
&raw,
|
||||||
|
"tok",
|
||||||
|
&["http://127.0.0.1:47820".to_string()],
|
||||||
|
&["127.0.0.1:47820".to_string()]
|
||||||
|
),
|
||||||
|
Verdict::Malformed,
|
||||||
|
"duplicate {} must be refused",
|
||||||
|
dup
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// find_head_end must report the index, and it must exclude the body.
|
||||||
|
#[test]
|
||||||
|
fn head_end_excludes_the_body() {
|
||||||
|
let raw = b"GET / HTTP/1.1\r\nHost: a\r\n\r\nBODYBYTES";
|
||||||
|
let end = find_head_end(raw).expect("terminator");
|
||||||
|
assert_eq!(&raw[..end], b"GET / HTTP/1.1\r\nHost: a\r\n\r\n");
|
||||||
|
assert!(!raw[..end].ends_with(b"BODYBYTES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TOKEN: &str = "s3cr3t-token-value";
|
||||||
|
|
||||||
|
fn origins() -> Vec<String> {
|
||||||
|
self_origins(47820)
|
||||||
|
}
|
||||||
|
fn authorities() -> Vec<String> {
|
||||||
|
host_authorities(47820)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head(request_line: &str, headers: &[&str]) -> String {
|
||||||
|
let mut s = String::from(request_line);
|
||||||
|
s.push_str("\r\n");
|
||||||
|
for h in headers {
|
||||||
|
s.push_str(h);
|
||||||
|
s.push_str("\r\n");
|
||||||
|
}
|
||||||
|
s.push_str("\r\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verdict(request_line: &str, headers: &[&str]) -> Verdict {
|
||||||
|
authorize(&head(request_line, headers), TOKEN, &origins(), &authorities())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_initial_document_is_allowed_by_its_query_token() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
&format!("GET /?token={} HTTP/1.1", TOKEN),
|
||||||
|
&["Host: 127.0.0.1:47820"]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wrong_token_is_not_enough() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("GET /?token=nope HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||||
|
Verdict::Unauthenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subresource_is_allowed_by_the_token_in_its_referer() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /assets/app.js HTTP/1.1",
|
||||||
|
&[
|
||||||
|
"Host: 127.0.0.1:47820",
|
||||||
|
&format!("Referer: http://127.0.0.1:47820/?token={}", TOKEN),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_websocket_upgrade_is_allowed_by_its_own_origin() {
|
||||||
|
// The viewer's CDP screencast socket carries Origin and no Referer, and
|
||||||
|
// its URL is not ours to add a token to.
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /ws HTTP/1.1",
|
||||||
|
&[
|
||||||
|
"Host: 127.0.0.1:47820",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
"Origin: http://127.0.0.1:47820",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subresource_is_allowed_by_fetch_metadata_when_the_referer_is_stripped() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /assets/app.js HTTP/1.1",
|
||||||
|
&[
|
||||||
|
"Host: 127.0.0.1:47820",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
"Sec-Fetch-Dest: script",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_site_fetch_metadata_is_refused() {
|
||||||
|
for site in ["cross-site", "same-site", "none"] {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /assets/app.js HTTP/1.1",
|
||||||
|
&["Host: 127.0.0.1:47820", &format!("Sec-Fetch-Site: {}", site)]
|
||||||
|
),
|
||||||
|
Verdict::Unauthenticated,
|
||||||
|
"Sec-Fetch-Site: {}",
|
||||||
|
site
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hostile_pages_fetch_is_refused_by_its_origin() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET / HTTP/1.1",
|
||||||
|
&["Host: 127.0.0.1:47820", "Origin: http://evil.example"]
|
||||||
|
),
|
||||||
|
Verdict::Unauthenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_port_scan_is_refused() {
|
||||||
|
// No token, no Origin, no Referer — a cross-site <script src>, a
|
||||||
|
// top-level navigation, or curl.
|
||||||
|
assert_eq!(
|
||||||
|
verdict("GET / HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||||
|
Verdict::Unauthenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dns_rebinding_is_refused_even_with_a_valid_token() {
|
||||||
|
// The attacker's name resolves to 127.0.0.1, but the Host header still
|
||||||
|
// says who the browser thinks it is talking to.
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
&format!("GET /?token={} HTTP/1.1", TOKEN),
|
||||||
|
&["Host: evil.example:47820"]
|
||||||
|
),
|
||||||
|
Verdict::BadHost
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localhost_is_an_acceptable_authority_and_origin() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /ws HTTP/1.1",
|
||||||
|
&["Host: localhost:47820", "Origin: http://localhost:47820"]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_panes_origin_does_not_authorize_this_one() {
|
||||||
|
// Ports are what separate one project's pane from another's, so the
|
||||||
|
// neighbouring port must not be accepted as "self".
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /ws HTTP/1.1",
|
||||||
|
&["Host: 127.0.0.1:47820", "Origin: http://127.0.0.1:47821"]
|
||||||
|
),
|
||||||
|
Verdict::Unauthenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cookie_borne_token_is_accepted() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /assets/app.js HTTP/1.1",
|
||||||
|
&[
|
||||||
|
"Host: 127.0.0.1:47820",
|
||||||
|
&format!("Cookie: other=1; {}={}", COOKIE_NAME, TOKEN),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_host_header_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(&format!("GET /?token={} HTTP/1.1", TOKEN), &[]),
|
||||||
|
Verdict::BadHost
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_names_are_matched_case_insensitively() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict(
|
||||||
|
"GET /ws HTTP/1.1",
|
||||||
|
&["HOST: 127.0.0.1:47820", "ORIGIN: http://127.0.0.1:47820"]
|
||||||
|
),
|
||||||
|
Verdict::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_connect_request_cannot_turn_this_into_a_forward_proxy() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("CONNECT evil.example:443 HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||||
|
Verdict::Malformed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_head_is_malformed() {
|
||||||
|
assert_eq!(authorize("", TOKEN, &origins(), &authorities()), Verdict::Malformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_head_terminator_is_found_for_both_crlf_and_lf() {
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\n"), Some(18));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\n"), Some(16));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_token_ignores_lookalike_parameters() {
|
||||||
|
assert_eq!(query_token("/?mytoken=a&token=b"), Some("b"));
|
||||||
|
assert_eq!(query_token("/?tokenish=a"), None);
|
||||||
|
assert_eq!(query_token("/nothing"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_match_rejects_prefixes_and_suffixes() {
|
||||||
|
assert!(tokens_match(TOKEN, TOKEN));
|
||||||
|
assert!(!tokens_match(&TOKEN[..5], TOKEN));
|
||||||
|
assert!(!tokens_match(&format!("{}x", TOKEN), TOKEN));
|
||||||
|
assert!(!tokens_match("", TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_proxy_port_range_is_the_one_the_csp_enumerates() {
|
||||||
|
// tauri.conf.json lists these origins in `frame-src`; a change here
|
||||||
|
// without a change there yields a pane that is silently blocked.
|
||||||
|
assert_eq!(PROXY_PORTS.clone().count(), 8);
|
||||||
|
assert_eq!(*PROXY_PORTS.start(), 47820);
|
||||||
|
assert_eq!(*PROXY_PORTS.end(), 47827);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! IPC surface for the auth bridge. The mechanism lives in
|
||||||
|
//! [`crate::auth_bridge`]; this file only translates between it and the
|
||||||
|
//! frontend, and keeps the persisted per-project flag in step.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::auth_bridge::AuthBridgeStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Turn the bridge on or off for a project and return the resulting status.
|
||||||
|
///
|
||||||
|
/// Enabling starts polling immediately when the container is already running;
|
||||||
|
/// otherwise the flag is simply persisted and `start_project_container` arms the
|
||||||
|
/// bridge on the next start. This is a host-side feature, so no container
|
||||||
|
/// recreation is involved either way.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auth_bridge_enabled(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, enabled)?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
if let Some(container_id) = project.container_id {
|
||||||
|
if crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Awaits the poller, so every host port is released before we return.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_bridge_status(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
let enabled = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.auth_bridge_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::models::Project;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Resolve AWS profile: project-level → global settings → "default".
|
||||||
|
pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String {
|
||||||
|
project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.aws_profile.clone())
|
||||||
|
.or_else(|| global_profile.map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "default".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the AWS session is valid for the given profile on the host.
|
||||||
|
/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid.
|
||||||
|
pub async fn check_sso_session(profile: &str) -> Result<bool, String> {
|
||||||
|
let output = tokio::process::Command::new("aws")
|
||||||
|
.args(["sts", "get-caller-identity", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?;
|
||||||
|
Ok(output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured).
|
||||||
|
pub async fn is_sso_profile(profile: &str) -> Result<bool, String> {
|
||||||
|
let check_start_url = tokio::process::Command::new("aws")
|
||||||
|
.args(["configure", "get", "sso_start_url", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(out) = check_start_url {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let check_session = tokio::process::Command::new("aws")
|
||||||
|
.args(["configure", "get", "sso_session", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(out) = check_session {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser).
|
||||||
|
pub async fn run_sso_login(profile: &str) -> Result<(), String> {
|
||||||
|
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||||
|
|
||||||
|
let status = tokio::process::Command::new("aws")
|
||||||
|
.args(["sso", "login", "--profile", profile])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err("SSO login failed or was cancelled".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn aws_sso_refresh(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state.projects_store.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let profile = resolve_profile_for_project(
|
||||||
|
&project,
|
||||||
|
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
run_sso_login(&profile).await
|
||||||
|
}
|
||||||
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
|||||||
docker::get_container_info(&project).await
|
docker::get_container_info(&project).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
|
||||||
let containers = docker::list_sibling_containers().await?;
|
|
||||||
let result: Vec<serde_json::Value> = containers
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| {
|
|
||||||
serde_json::json!({
|
|
||||||
"id": c.id,
|
|
||||||
"names": c.names,
|
|
||||||
"image": c.image,
|
|
||||||
"state": c.state,
|
|
||||||
"status": c.status,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Tauri commands for the model gateway container.
|
||||||
|
//!
|
||||||
|
//! Mirrors `stt_commands`. The one rule that is specific to this module: the
|
||||||
|
//! **provider API key never crosses back to the frontend**. It goes in through
|
||||||
|
//! `set_gateway_api_key`, lives in the OS keychain, and is only ever read
|
||||||
|
//! host-side when rendering the gateway config. `get_gateway_status` reports
|
||||||
|
//! its presence as a boolean.
|
||||||
|
//!
|
||||||
|
//! The gateway *master key* is different and is returned deliberately — it is
|
||||||
|
//! the value the user has to paste into a project's model config as its auth
|
||||||
|
//! token, so keeping it hidden would just make the feature unusable.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::docker::gateway;
|
||||||
|
use crate::models::GatewayStatus;
|
||||||
|
use crate::storage::secure;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_gateway_status(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
gateway::get_gateway_status(&settings.gateway).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_gateway(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
gateway::ensure_gateway_running(&settings.gateway).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_gateway() -> Result<(), String> {
|
||||||
|
gateway::stop_gateway_container().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the gateway is actually answering yet. LiteLLM needs a few seconds
|
||||||
|
/// after the container starts before `/v1/messages` will serve anything.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_gateway_health(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
gateway::check_gateway_health(settings.gateway.port).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_gateway_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
gateway::build_gateway_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("gateway-build-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pull_gateway_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
gateway::pull_gateway_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("gateway-pull-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the upstream provider API key. Write-only from the frontend's point
|
||||||
|
/// of view — there is no matching getter.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_gateway_api_key(api_key: String) -> Result<(), String> {
|
||||||
|
secure::store_gateway_api_key(&api_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the provider API key. The gateway keeps serving until it is
|
||||||
|
/// restarted, at which point it will refuse to start without a key.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_gateway_api_key() -> Result<(), String> {
|
||||||
|
secure::delete_gateway_api_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token a project sends to the gateway (`ANTHROPIC_AUTH_TOKEN`), minting
|
||||||
|
/// one on first use.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_gateway_auth_token() -> Result<String, String> {
|
||||||
|
secure::get_or_create_gateway_master_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a new gateway auth token, invalidating the old one. Projects still
|
||||||
|
/// holding the previous value stop working until they are updated, and the
|
||||||
|
/// gateway is recreated on its next start because the rotation id moved.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn regenerate_gateway_auth_token() -> Result<String, String> {
|
||||||
|
secure::regenerate_gateway_master_key()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const HELP_URL: &str =
|
||||||
|
"https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
|
||||||
|
|
||||||
|
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
||||||
|
|
||||||
|
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
|
||||||
|
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Return the help markdown content.
|
||||||
|
///
|
||||||
|
/// On the first call, tries to fetch the latest version from the gitea repo.
|
||||||
|
/// If that fails (network error, timeout, etc.), falls back to the version
|
||||||
|
/// embedded at compile time. The result is cached for the rest of the session.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_help_content() -> Result<String, String> {
|
||||||
|
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
|
||||||
|
let mut guard = mutex.lock().await;
|
||||||
|
|
||||||
|
if let Some(ref cached) = *guard {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match fetch_remote_help().await {
|
||||||
|
Ok(md) => {
|
||||||
|
log::info!("Loaded help content from remote repo");
|
||||||
|
md
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("Using embedded help content (remote fetch failed: {})", e);
|
||||||
|
EMBEDDED_HELP.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*guard = Some(content.clone());
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_remote_help() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(HELP_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Remote returned status {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", e))
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::install_helper::{self, InstallOptions};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_install_options() -> Result<InstallOptions, String> {
|
||||||
|
Ok(install_helper::detect_install_options())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
install_helper::platform::run_install(&app_handle).await
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
|
pub mod auth_bridge_commands;
|
||||||
|
pub mod auth_token_commands;
|
||||||
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod file_commands;
|
||||||
|
pub mod file_viewer_commands;
|
||||||
|
pub mod gateway_commands;
|
||||||
|
pub mod help_commands;
|
||||||
|
pub mod inspect_commands;
|
||||||
|
pub mod install_helper_commands;
|
||||||
|
pub mod migration_commands;
|
||||||
|
pub mod notes_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod settings_export_commands;
|
||||||
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
pub mod web_terminal_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)
|
||||||
|
}
|
||||||
@@ -1,43 +1,175 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
|
use crate::models::gateway_settings::GatewaySettings;
|
||||||
use crate::models::AppSettings;
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
|
||||||
secure::store_api_key(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn has_api_key() -> Result<bool, String> {
|
|
||||||
secure::has_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_api_key() -> Result<(), String> {
|
|
||||||
secure::delete_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything `update_settings` refuses a save over, run against the store's
|
||||||
|
/// *current* value and the incoming one.
|
||||||
|
///
|
||||||
|
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||||
|
/// settings save — `settings_export_commands::apply_settings_import`
|
||||||
|
/// restores three keychain secrets in the same command — can run this
|
||||||
|
/// *first* and bail before touching anything, rather than discovering the
|
||||||
|
/// rejection only when `update_settings` itself runs partway through.
|
||||||
|
pub fn validate_settings_update(
|
||||||
|
before: &AppSettings,
|
||||||
|
incoming: &AppSettings,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// The global half of the same rule the project half gets in
|
||||||
|
// `update_project`: a global custom env var is merged into every project's
|
||||||
|
// container environment, so an unchecked name here reaches all of them.
|
||||||
|
crate::models::validate_env_vars_update(
|
||||||
|
&before.global_custom_env_vars,
|
||||||
|
&incoming.global_custom_env_vars,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// The same for the two host paths this struct owns. `update_project`
|
||||||
|
// validated its per-project overrides and this side validated nothing,
|
||||||
|
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||||
|
// fallback for **every** project without an override
|
||||||
|
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||||
|
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||||
|
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||||
|
// home volume this release exists to bound.
|
||||||
|
//
|
||||||
|
// Grandfathered the same way project paths are: a value carried over
|
||||||
|
// unchanged still saves, so a store written before this check cannot lock
|
||||||
|
// the user out of their own settings.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"SSH key path",
|
||||||
|
before.default_ssh_key_path.as_deref(),
|
||||||
|
incoming.default_ssh_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"CA certificate path",
|
||||||
|
before.ca_cert_path.as_deref(),
|
||||||
|
incoming.ca_cert_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Third host path this struct owns, same reasoning: any project with
|
||||||
|
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||||
|
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||||
|
// here is a read-write bind mount of whatever it names into every such
|
||||||
|
// project's container.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"Docker socket path",
|
||||||
|
before.docker_socket_path.as_deref(),
|
||||||
|
incoming.docker_socket_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_settings(
|
pub async fn update_settings(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AppSettings, String> {
|
) -> Result<AppSettings, String> {
|
||||||
state.settings_store.update(settings)
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
validate_settings_update(&before, &settings)?;
|
||||||
|
|
||||||
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
|
// one settings block that owns a *container*, so a saved change that the
|
||||||
|
// running container doesn't reflect is a live desync, not a preference.
|
||||||
|
reconcile_gateway(&before.gateway, &saved.gateway).await;
|
||||||
|
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a settings save has to do to the gateway container to stay honest.
|
||||||
|
///
|
||||||
|
/// Kept separate from the IPC command and expressed over plain settings so the
|
||||||
|
/// decision is testable without Docker.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GatewayAction {
|
||||||
|
/// Nothing to do.
|
||||||
|
None,
|
||||||
|
/// The gateway is off — a container left running must be stopped.
|
||||||
|
StopIfRunning,
|
||||||
|
/// The published shape moved. A *running* container is now serving on the
|
||||||
|
/// old binding while status reports the new one, so it has to be recreated.
|
||||||
|
RestartIfRunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the container's published shape (as opposed to a purely cosmetic
|
||||||
|
/// field) changed. Provider, models and base URL all change the rendered
|
||||||
|
/// LiteLLM config, which is only read at boot.
|
||||||
|
fn gateway_shape_changed(before: &GatewaySettings, after: &GatewaySettings) -> bool {
|
||||||
|
before.port != after.port
|
||||||
|
|| before.provider.trim() != after.provider.trim()
|
||||||
|
|| before.api_base.as_deref().unwrap_or("").trim()
|
||||||
|
!= after.api_base.as_deref().unwrap_or("").trim()
|
||||||
|
|| before.valid_models() != after.valid_models()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gateway_action(before: &GatewaySettings, after: &GatewaySettings) -> GatewayAction {
|
||||||
|
if !after.enabled {
|
||||||
|
// Includes the case where it was already disabled: a container found
|
||||||
|
// running while the feature is off should not stay up.
|
||||||
|
return GatewayAction::StopIfRunning;
|
||||||
|
}
|
||||||
|
if gateway_shape_changed(before, after) {
|
||||||
|
return GatewayAction::RestartIfRunning;
|
||||||
|
}
|
||||||
|
GatewayAction::None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply [`gateway_action`]. Never fails the settings save: the settings *are*
|
||||||
|
/// saved by this point, and a Docker hiccup must not make the UI think they
|
||||||
|
/// weren't. Both paths are no-ops when no container exists, so this stays cheap
|
||||||
|
/// on the overwhelmingly common "gateway not in use" save.
|
||||||
|
async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||||
|
let action = gateway_action(before, after);
|
||||||
|
if action == GatewayAction::None {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (exists, running) = match docker::gateway::gateway_container_presence().await {
|
||||||
|
Ok(presence) => presence,
|
||||||
|
// Docker down: there is nothing running to desync from.
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Gateway reconcile skipped ({})", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !exists || !running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match action {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayAction::RestartIfRunning => {
|
||||||
|
log::info!("Model gateway settings changed — recreating the container");
|
||||||
|
// The fingerprint no longer matches, so this stops, removes and
|
||||||
|
// recreates with the new port/config in one step.
|
||||||
|
if let Err(e) = docker::gateway::ensure_gateway_running(after).await {
|
||||||
|
log::error!("Failed to apply the new model gateway settings: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayAction::None => unreachable!(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pull_image(
|
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
image_name: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
docker::pull_image(&image_name, move |msg| {
|
docker::pull_image(&image_name, move |msg| {
|
||||||
let _ = app_handle.emit("image-pull-progress", msg);
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
@@ -45,6 +177,33 @@ pub async fn pull_image(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||||
|
// Try the iana-time-zone crate first (cross-platform)
|
||||||
|
match iana_time_zone::get_timezone() {
|
||||||
|
Ok(tz) => return Ok(tz),
|
||||||
|
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check TZ env var
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read /etc/timezone (Linux)
|
||||||
|
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
||||||
|
let tz = tz.trim().to_string();
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to UTC if detection fails
|
||||||
|
Ok("UTC".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
@@ -56,6 +215,78 @@ pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the UI shows next to a corporate CA certificate path.
|
||||||
|
///
|
||||||
|
/// Errors are returned *inside* the payload rather than as `Err` so the field
|
||||||
|
/// can render its own inline message while the user is still typing — a toast
|
||||||
|
/// per keystroke would be unusable. The same check runs again, as a hard error,
|
||||||
|
/// when the container is created.
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct CaCertInfo {
|
||||||
|
pub exists: bool,
|
||||||
|
pub is_directory: bool,
|
||||||
|
/// How many certificate files were found.
|
||||||
|
pub cert_count: usize,
|
||||||
|
/// The names they will be installed as inside the container. Surfacing
|
||||||
|
/// these makes the silent `.pem` → `.crt` rename visible, which is the one
|
||||||
|
/// step users most often do by hand and get wrong.
|
||||||
|
pub installed_names: Vec<String>,
|
||||||
|
/// Why the path is unusable, if it is.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn inspect_ca_cert_path(path: String) -> Result<CaCertInfo, String> {
|
||||||
|
use crate::docker::ca_certs;
|
||||||
|
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(CaCertInfo {
|
||||||
|
exists: false,
|
||||||
|
is_directory: false,
|
||||||
|
cert_count: 0,
|
||||||
|
installed_names: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = std::path::Path::new(trimmed);
|
||||||
|
let exists = p.exists();
|
||||||
|
let is_directory = p.is_dir();
|
||||||
|
|
||||||
|
match ca_certs::resolve(Some(trimmed)) {
|
||||||
|
Ok(Some(resolved)) => Ok(CaCertInfo {
|
||||||
|
exists,
|
||||||
|
is_directory,
|
||||||
|
cert_count: resolved.cert_files.len(),
|
||||||
|
installed_names: resolved
|
||||||
|
.cert_files
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
ca_certs::container_cert_name(
|
||||||
|
&f.file_name().unwrap_or_default().to_string_lossy(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Ok(None) => Ok(CaCertInfo {
|
||||||
|
exists,
|
||||||
|
is_directory,
|
||||||
|
cert_count: 0,
|
||||||
|
installed_names: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(CaCertInfo {
|
||||||
|
exists,
|
||||||
|
is_directory,
|
||||||
|
cert_count: 0,
|
||||||
|
installed_names: Vec::new(),
|
||||||
|
error: Some(e),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
||||||
let mut profiles = Vec::new();
|
let mut profiles = Vec::new();
|
||||||
@@ -104,3 +335,99 @@ pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
|||||||
|
|
||||||
Ok(profiles)
|
Ok(profiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::gateway_settings::GatewayModel;
|
||||||
|
|
||||||
|
fn enabled_gateway() -> GatewaySettings {
|
||||||
|
GatewaySettings {
|
||||||
|
enabled: true,
|
||||||
|
port: 4000,
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
api_base: None,
|
||||||
|
models: vec![GatewayModel {
|
||||||
|
name: "gpt-5.1".to_string(),
|
||||||
|
model_id: "gpt-5.1".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabling_the_gateway_stops_it() {
|
||||||
|
// The bug: turning the toggle off only persisted `enabled: false` and
|
||||||
|
// hid the Stop button, leaving a container serving with no way to stop
|
||||||
|
// it.
|
||||||
|
let before = enabled_gateway();
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.enabled = false;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_the_port_reconciles_the_container() {
|
||||||
|
// Otherwise status reports the new port while the container keeps the
|
||||||
|
// old binding, and every project gets a broken ANTHROPIC_BASE_URL.
|
||||||
|
let before = enabled_gateway();
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.port = 4100;
|
||||||
|
assert_eq!(
|
||||||
|
gateway_action(&before, &after),
|
||||||
|
GatewayAction::RestartIfRunning
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_changes_that_only_take_effect_at_boot_reconcile_too() {
|
||||||
|
let before = enabled_gateway();
|
||||||
|
|
||||||
|
let mut provider = before.clone();
|
||||||
|
provider.provider = "groq".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
gateway_action(&before, &provider),
|
||||||
|
GatewayAction::RestartIfRunning
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut api_base = before.clone();
|
||||||
|
api_base.api_base = Some("https://example.test/v1".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
gateway_action(&before, &api_base),
|
||||||
|
GatewayAction::RestartIfRunning
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut models = before.clone();
|
||||||
|
models.models[0].model_id = "gpt-4.1".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
gateway_action(&before, &models),
|
||||||
|
GatewayAction::RestartIfRunning
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saving_an_unchanged_or_half_typed_gateway_touches_nothing() {
|
||||||
|
let before = enabled_gateway();
|
||||||
|
assert_eq!(gateway_action(&before, &before), GatewayAction::None);
|
||||||
|
|
||||||
|
// Whitespace-only edits don't reach the rendered config.
|
||||||
|
let mut trimmed = before.clone();
|
||||||
|
trimmed.provider = " openai ".to_string();
|
||||||
|
trimmed.api_base = Some(" ".to_string());
|
||||||
|
assert_eq!(gateway_action(&before, &trimmed), GatewayAction::None);
|
||||||
|
|
||||||
|
// A half-filled model row is skipped when rendering, so it must not
|
||||||
|
// bounce a live container either.
|
||||||
|
let mut half_typed = before.clone();
|
||||||
|
half_typed.models.push(GatewayModel {
|
||||||
|
name: "gpt".to_string(),
|
||||||
|
model_id: String::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||||
|
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||||
|
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||||
|
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||||
|
//! out of scope: per-project settings, per-project secrets, and anything
|
||||||
|
//! living in a project's Docker volumes.
|
||||||
|
//!
|
||||||
|
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||||
|
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||||
|
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||||
|
//! host path string is the exact shape of bug that produced this app's past
|
||||||
|
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||||
|
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||||
|
//! settings_import` resolves the chosen path itself and remembers it
|
||||||
|
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||||
|
//! the same file without the path ever crossing back over IPC.
|
||||||
|
//!
|
||||||
|
//! The *decrypted payload* is not cached between preview and apply — the
|
||||||
|
//! password the frontend passes to each call is what it already held for
|
||||||
|
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||||
|
//! keeps the plaintext itself — export/import secrets included — around for
|
||||||
|
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||||
|
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||||
|
//!
|
||||||
|
//! **This is new attack surface**: a settings export is a file one person
|
||||||
|
//! can hand another and ask them to import, together with a password, and
|
||||||
|
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||||
|
//! wholesale — see the module doc on `models::settings_export` for the
|
||||||
|
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||||
|
//! and treat that as the standing example of the class of thing to keep
|
||||||
|
//! checking for here, not a one-off fixed bug.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||||
|
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
use crate::storage::{secure, settings_crypto};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||||
|
/// whether the file it's about to re-read is the same one the user actually
|
||||||
|
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||||
|
/// on what gets applied — without this, a file replaced on disk between the
|
||||||
|
/// two calls (this app's own stated threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||||
|
/// apply silently different content than what the confirmation dialog showed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingSettingsImport {
|
||||||
|
path: PathBuf,
|
||||||
|
ciphertext_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(data).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXTENSION: &str = "triplec";
|
||||||
|
|
||||||
|
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||||
|
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||||
|
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||||
|
/// attacker who can just try a three-character password directly.
|
||||||
|
const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
||||||
|
fn suggested_export_name() -> String {
|
||||||
|
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||||
|
// earlier file just because the save dialog defaults to the same name.
|
||||||
|
format!(
|
||||||
|
"triple-c-settings-{}.{}",
|
||||||
|
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||||
|
FILE_EXTENSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Export Triple-C settings")
|
||||||
|
.set_file_name(suggested)
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.save_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Import Triple-C settings")
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.pick_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||||
|
/// export with the web-terminal token blanked out of it — see the module
|
||||||
|
/// doc comment on `models::settings_export` for why that field cannot
|
||||||
|
/// travel through `settings` like the rest of this struct.
|
||||||
|
///
|
||||||
|
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||||
|
/// treated as "nothing to export" for that one entry rather than aborting
|
||||||
|
/// the whole export, matching how the rest of this app degrades a keychain
|
||||||
|
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||||
|
/// rather than surfacing it as a hard failure.
|
||||||
|
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||||
|
let mut settings = current;
|
||||||
|
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||||
|
|
||||||
|
let secrets = ExportedSecrets {
|
||||||
|
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||||
|
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||||
|
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||||
|
web_terminal_access_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
(settings, secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the current global settings and secrets to a password-encrypted
|
||||||
|
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||||
|
/// deliberately distinguishable from one so the frontend shows nothing
|
||||||
|
/// rather than a "failed" toast for a plain cancel.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_settings(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||||
|
// close as this pair of languages allows to the frontend's `.length`
|
||||||
|
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||||
|
// characters, which no reasonable password touches.
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"Use a password of at least {} characters.",
|
||||||
|
MIN_PASSWORD_LEN
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||||
|
if secrets.is_empty() {
|
||||||
|
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(
|
||||||
|
serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||||
|
);
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||||
|
|
||||||
|
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||||
|
/// preview (counts and presence flags only — never a secret value) for a
|
||||||
|
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||||
|
///
|
||||||
|
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||||
|
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||||
|
/// against — does **not** remember the decrypted payload itself, so the
|
||||||
|
/// password must be supplied again to actually apply it — seeing the preview
|
||||||
|
/// is not the same as committing to it. The hash exists so it also can't be
|
||||||
|
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||||
|
/// proceed if the file on disk no longer matches what was just previewed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_settings_import(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<SettingsImportPreview>, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to open a settings export.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(path) = pick_import_open_path(&window).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
|
||||||
|
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||||
|
path,
|
||||||
|
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Some(preview))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||||
|
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||||
|
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||||
|
/// required, not just encouraged, since it is the only place a user is told
|
||||||
|
/// what an import is about to touch before it touches it. That requirement
|
||||||
|
/// is only real if the file can't change out from under it, so this also
|
||||||
|
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||||
|
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||||
|
/// two calls (this feature's own threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) must not be able
|
||||||
|
/// to apply silently different content than what the confirmation dialog
|
||||||
|
/// showed.
|
||||||
|
///
|
||||||
|
/// Global settings are replaced wholesale — an import is "restore this
|
||||||
|
/// environment," not a field-by-field merge. Global secrets are handled
|
||||||
|
/// differently and on purpose: **only secrets actually present in the
|
||||||
|
/// import are written**; a secret the export doesn't have is left alone on
|
||||||
|
/// this machine rather than cleared, because an absent secret in the export
|
||||||
|
/// means "the source machine never had this configured," not "delete this
|
||||||
|
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||||
|
/// for that (signing out of shared auth, clearing the gateway key).
|
||||||
|
///
|
||||||
|
/// Order matters here, twice over.
|
||||||
|
///
|
||||||
|
/// First: the imported settings are **validated before any secret is
|
||||||
|
/// written**, using the same checks `update_settings` itself runs
|
||||||
|
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||||
|
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||||
|
/// host path used to be caught only when `update_settings` ran, by which
|
||||||
|
/// point the three keychain secrets below were already overwritten with the
|
||||||
|
/// file's, each with a fresh rotation id, silently flagging every project
|
||||||
|
/// container for recreation — while the error the user saw talked only
|
||||||
|
/// about the rejected setting and said nothing about the credentials that
|
||||||
|
/// had already moved. Failing this check first makes a rejected import
|
||||||
|
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||||
|
/// mean.
|
||||||
|
///
|
||||||
|
/// Second, among the things that *do* get written: secrets are restored
|
||||||
|
/// **before** the settings replace runs (which is what triggers
|
||||||
|
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||||
|
/// the final key material rather than racing it — restoring the other way
|
||||||
|
/// round left a real window where the running gateway and the keychain
|
||||||
|
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||||
|
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||||
|
/// nudges a running gateway container to recreate itself whenever a secret
|
||||||
|
/// this import carried was actually written — otherwise the running
|
||||||
|
/// container keeps serving the old key material indefinitely while every
|
||||||
|
/// project container is handed the new one.
|
||||||
|
///
|
||||||
|
/// A keychain write failing is reported back rather than only logged: an
|
||||||
|
/// import that silently restores two of three secrets but not the third
|
||||||
|
/// must not read as unqualified success.
|
||||||
|
///
|
||||||
|
/// The pending import is only cleared on success. A failure here (rejected
|
||||||
|
/// by the validation above, a stale-file mismatch, or some other error)
|
||||||
|
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||||
|
/// making them pick the file and re-enter the password again — the
|
||||||
|
/// preview's job was confirming *what* to import, not spending the one
|
||||||
|
/// attempt at applying it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_settings_import(
|
||||||
|
password: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SettingsImportOutcome, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to import settings.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = state
|
||||||
|
.pending_settings_import
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&pending.path)
|
||||||
|
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||||
|
return Err(
|
||||||
|
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
|
||||||
|
let current = state.settings_store.get();
|
||||||
|
|
||||||
|
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||||
|
// the keychain, so "leave an absent secret alone" has to be done by
|
||||||
|
// hand here: carry the destination's current token forward when the
|
||||||
|
// import doesn't have one, instead of letting the wholesale replace
|
||||||
|
// below blank it (every export writes `None` there — see
|
||||||
|
// `split_settings_and_secrets`).
|
||||||
|
let mut settings = payload.settings;
|
||||||
|
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||||
|
.or_else(|| current.web_terminal.access_token.clone());
|
||||||
|
|
||||||
|
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||||
|
|
||||||
|
let mut secret_restore_warnings = Vec::new();
|
||||||
|
let mut gateway_secret_changed = false;
|
||||||
|
|
||||||
|
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||||
|
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the shared Claude login: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||||
|
match secure::store_gateway_api_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings.push(format!(
|
||||||
|
"Could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||||
|
match secure::store_gateway_master_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway master key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore the gateway master key: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved =
|
||||||
|
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||||
|
|
||||||
|
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||||
|
// *shape* — port, provider, base URL, models — because that's what's
|
||||||
|
// rendered into the container's config. A secret changing with the shape
|
||||||
|
// held constant is invisible to it, so a running gateway container would
|
||||||
|
// otherwise keep serving the old key material forever after an import
|
||||||
|
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||||
|
// (which does include the secret rotation id) means the *next* unrelated
|
||||||
|
// settings save would suddenly and confusingly recreate it instead.
|
||||||
|
if gateway_secret_changed && saved.gateway.enabled {
|
||||||
|
match crate::docker::gateway::gateway_container_presence().await {
|
||||||
|
Ok((true, true)) => {
|
||||||
|
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pending_settings_import.lock().await.take();
|
||||||
|
|
||||||
|
Ok(SettingsImportOutcome {
|
||||||
|
settings: saved,
|
||||||
|
secret_restore_warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<String>) -> Option<String> {
|
||||||
|
value.filter(|v| !v.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||||
|
/// of the payload is even worth attempting to parse.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FormatVersionProbe {
|
||||||
|
format_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and decrypt an export file at `path`, then parse it — see
|
||||||
|
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||||
|
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||||
|
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||||
|
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||||
|
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||||
|
/// exists now for tests that don't need that.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let encrypted =
|
||||||
|
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
read_and_decrypt_bytes(&encrypted, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||||
|
/// format version **before** attempting to deserialize the full payload.
|
||||||
|
///
|
||||||
|
/// That ordering is not just tidiness: a version bump that isn't
|
||||||
|
/// deserialize-compatible (a field's type changes, not just a new
|
||||||
|
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||||
|
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||||
|
/// before the version check ever ran, surfacing a raw parse error instead
|
||||||
|
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||||
|
/// errors quote the offending value inline. This file is not attacker
|
||||||
|
/// content in the usual sense (it must still decrypt under the right
|
||||||
|
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||||
|
/// so neither error path below ever interpolates what `serde_json`
|
||||||
|
/// actually says — only a fixed, generic message.
|
||||||
|
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||||
|
|
||||||
|
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||||
|
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||||
|
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||||
|
Update Triple-C before importing it.",
|
||||||
|
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||||
|
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_blank_treats_whitespace_only_as_absent() {
|
||||||
|
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||||
|
assert_eq!(non_blank(Some("".to_string())), None);
|
||||||
|
assert_eq!(non_blank(None), None);
|
||||||
|
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||||
|
// What `apply_settings_import` compares against the pinned hash from
|
||||||
|
// `preview_settings_import` to detect a file swapped out from under a
|
||||||
|
// pending import — this only defends anything if identical bytes
|
||||||
|
// always hash identically and any change to those bytes changes the
|
||||||
|
// hash.
|
||||||
|
let bytes = b"pretend this is an encrypted export file";
|
||||||
|
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||||
|
|
||||||
|
let mut tampered = bytes.to_vec();
|
||||||
|
tampered[0] ^= 0xFF;
|
||||||
|
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
payload: &SettingsExportPayload,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||||
|
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||||
|
/// deliberately not shape-compatible, which the typed helper above can't
|
||||||
|
/// produce at all.
|
||||||
|
fn write_raw_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
let plaintext = serde_json::to_vec(value).unwrap();
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, &encrypted).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(
|
||||||
|
secrets.web_terminal_access_token,
|
||||||
|
Some("super-secret-token".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(secrets.web_terminal_access_token, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||||
|
SettingsExportPayload {
|
||||||
|
format_version,
|
||||||
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||||
|
app_version: "0.4.14".to_string(),
|
||||||
|
settings: AppSettings::default(),
|
||||||
|
secrets: ExportedSecrets::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-settings-export-test-{}-{}",
|
||||||
|
name,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||||
|
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||||
|
// future version could easily have changed `settings` from an object
|
||||||
|
// to something else) as well as newer — so this only passes under
|
||||||
|
// the probe-first ordering. Parsing the full struct first (the old
|
||||||
|
// behavior) would fail on the shape mismatch and never reach the
|
||||||
|
// version check, producing the "unexpected shape" message instead of
|
||||||
|
// "newer version" / "Update Triple-C".
|
||||||
|
let dir = temp_dir("newer-format");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "9.9.9",
|
||||||
|
"settings": "this-app-version-stores-settings-differently",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||||
|
assert!(err.contains("Update Triple-C"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_at_the_current_format_is_accepted() {
|
||||||
|
let dir = temp_dir("current-format");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||||
|
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||||
|
// A `format_version` the probe accepts, but a `settings` field of
|
||||||
|
// the wrong *type* rather than just a missing field — this is what
|
||||||
|
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||||
|
// struct AppSettings" error that quotes the offending value
|
||||||
|
// verbatim. That value here stands in for plaintext that, in a real
|
||||||
|
// export, could be a live credential — the assertion below is only
|
||||||
|
// meaningful against a fixture that actually exercises serde's
|
||||||
|
// value-quoting behavior, which a merely-missing-field fixture does
|
||||||
|
// not.
|
||||||
|
let dir = temp_dir("malformed");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "0.4.14",
|
||||||
|
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||||
|
"leaked plaintext into the error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
assert!(err.contains("doesn't look like a valid settings export"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||||
|
let dir = temp_dir("wrong-password");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("Wrong password"),
|
||||||
|
"unexpected message: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::docker::stt;
|
||||||
|
use crate::models::app_settings::SttStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_stt_status(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
stt::get_stt_status(&settings.stt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_stt(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
stt::ensure_stt_running(&settings.stt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_stt() -> Result<(), String> {
|
||||||
|
stt::stop_stt_container().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
stt::build_stt_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("stt-build-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pull_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
stt::pull_stt_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("stt-pull-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn transcribe_audio(
|
||||||
|
audio_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
if !settings.stt.enabled {
|
||||||
|
return Err("STT is not enabled".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("http://127.0.0.1:{}/transcribe", settings.stt.port);
|
||||||
|
|
||||||
|
let file_part = reqwest::multipart::Part::bytes(audio_data)
|
||||||
|
.file_name("recording.wav")
|
||||||
|
.mime_str("audio/wav")
|
||||||
|
.map_err(|e| format!("Failed to create multipart: {}", e))?;
|
||||||
|
|
||||||
|
let mut form = reqwest::multipart::Form::new().part("file", file_part);
|
||||||
|
|
||||||
|
if let Some(ref lang) = settings.stt.language {
|
||||||
|
form = form.text("language", lang.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.is_connect() {
|
||||||
|
"STT container is not running. Start it from Settings.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Transcription request failed: {}", e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Transcription failed ({}): {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
|
||||||
|
|
||||||
|
result["text"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "No text in transcription response".to_string())
|
||||||
|
}
|
||||||
@@ -1,11 +1,140 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::commands::aws_commands;
|
||||||
|
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Build the command to run in the container terminal.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 script = format!(
|
||||||
|
r#"
|
||||||
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
|
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "AWS session valid."
|
||||||
|
else
|
||||||
|
echo "AWS session expired or invalid."
|
||||||
|
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
|
||||||
|
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||||
|
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "Starting SSO login..."
|
||||||
|
echo ""
|
||||||
|
triple-c-sso-refresh
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||||
|
echo "You may see authentication errors."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||||
|
echo "Starting Claude anyway..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
{update_prelude}
|
||||||
|
{claude_cmd}
|
||||||
|
"#,
|
||||||
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
|
claude_cmd = claude_cmd
|
||||||
|
);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
script,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_terminal_session(
|
pub async fn open_terminal_session(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
session_type: Option<String>,
|
||||||
|
session_name: Option<String>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -19,10 +148,10 @@ pub async fn open_terminal_session(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Container not running".to_string())?;
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
let cmd = vec![
|
let cmd = match session_type.as_deref() {
|
||||||
"claude".to_string(),
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
"--dangerously-skip-permissions".to_string(),
|
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
||||||
];
|
};
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
let exit_event = format!("terminal-exit-{}", session_id);
|
let exit_event = format!("terminal-exit-{}", session_id);
|
||||||
@@ -69,6 +198,335 @@ pub async fn close_terminal_session(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Close audio bridge if it exists
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
|
// Close terminal session
|
||||||
state.exec_manager.close_session(&session_id).await;
|
state.exec_manager.close_session(&session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn paste_image_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
image_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let file_name = format!("clipboard_{}.png", timestamp);
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a host file (e.g. dragged onto the terminal) into the container so
|
||||||
|
/// Claude Code can read it, and return the in-container path. Mirrors the
|
||||||
|
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
|
||||||
|
/// original name. Returns an error for paths that aren't readable regular files
|
||||||
|
/// (e.g. a dropped directory).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_host_file_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
host_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||||
|
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||||
|
// no traversal, and nothing whose path passes through a hidden directory
|
||||||
|
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||||
|
// the path with its symlinks already resolved, so a visible directory that
|
||||||
|
// *leads* to one of those is refused too. What comes back is that resolved
|
||||||
|
// path, and it is what gets opened. Four commands touch a host path now,
|
||||||
|
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||||
|
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||||
|
// open their dialog from Rust instead, so for them the policy above is
|
||||||
|
// defence in depth and for these two it is the boundary itself.
|
||||||
|
// The name is taken from the path the user actually dropped, *before*
|
||||||
|
// resolution. Deriving it from the resolved path renames the file behind
|
||||||
|
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||||
|
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||||
|
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||||
|
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||||
|
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
|
// regular file, reports `len() == 0`, and passes both the directory check
|
||||||
|
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||||
|
// with no writer, with no timeout anywhere on this path. The upload then
|
||||||
|
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||||
|
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||||
|
// shape. This is one of two routes for getting a host file into a
|
||||||
|
// container (the Files pane's upload is the other), so it is the wrong
|
||||||
|
// place to be clever.
|
||||||
|
if !meta.is_file() {
|
||||||
|
return Err(if meta.is_dir() {
|
||||||
|
format!("{} is a directory — drop individual files", host_path)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||||
|
host_path
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
|
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
||||||
|
// with the code that does the reading, which re-applies it to the open
|
||||||
|
// descriptor — this check is here only so the refusal reads like a sentence
|
||||||
|
// instead of arriving after a 300 MB read.
|
||||||
|
use crate::docker::exec::MAX_DROP_BYTES;
|
||||||
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||||
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Ensure the destination directory exists rather than relying on Docker's
|
||||||
|
// archive extractor to create the parent for the uploaded tar entry.
|
||||||
|
crate::docker::exec::exec_oneshot(
|
||||||
|
&container_id,
|
||||||
|
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let file_name = format!("triple-c-drops/{}", base);
|
||||||
|
crate::docker::exec::upload_host_file_to_container(
|
||||||
|
&container_id,
|
||||||
|
&host_path,
|
||||||
|
"/tmp",
|
||||||
|
&file_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_audio_bridge(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Get container_id from the terminal session
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||||
|
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
let cmd = vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.create_session_with_tty(
|
||||||
|
&container_id,
|
||||||
|
&audio_session_id,
|
||||||
|
cmd,
|
||||||
|
false,
|
||||||
|
|_data| { /* ignore output from the audio bridge */ },
|
||||||
|
Box::new(|| { /* no exit handler needed */ }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_audio_data(
|
||||||
|
session_id: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.send_input(&audio_session_id, data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_audio_bridge(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||||
|
use crate::models::Project;
|
||||||
|
|
||||||
|
/// A dropped file must be named the way the *user* named it.
|
||||||
|
///
|
||||||
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||||
|
/// name from the path *after* symlink resolution, so dropping
|
||||||
|
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||||
|
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||||
|
/// target's name. Nothing errored; the user just got a name they never
|
||||||
|
/// typed.
|
||||||
|
///
|
||||||
|
/// This asserts the shared helper's contract from the terminal side: the
|
||||||
|
/// answer comes from the spelling, and a path that does not name a file is
|
||||||
|
/// refused rather than silently substituted (it used to fall back to
|
||||||
|
/// `"dropped-file"`).
|
||||||
|
/// A `Project` with only the fields these tests care about set; the rest
|
||||||
|
/// come through serde so the test does not have to track every field.
|
||||||
|
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "p1",
|
||||||
|
"name": "Test",
|
||||||
|
"paths": [],
|
||||||
|
"container_id": null,
|
||||||
|
"status": "running",
|
||||||
|
"backend": backend,
|
||||||
|
"bedrock_config": bedrock_config,
|
||||||
|
"ollama_config": null,
|
||||||
|
"openai_compatible_config": null,
|
||||||
|
"allow_docker_access": false,
|
||||||
|
"full_permissions": false,
|
||||||
|
"ssh_key_path": null,
|
||||||
|
"git_user_name": null,
|
||||||
|
"git_user_email": null,
|
||||||
|
"created_at": "now",
|
||||||
|
"updated_at": "now"
|
||||||
|
}))
|
||||||
|
.expect("test project deserializes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Claude session updates the CLI before launching it.
|
||||||
|
///
|
||||||
|
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||||
|
/// containers are long-lived, so a stale CLI is the normal case without
|
||||||
|
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||||
|
/// wrapper rather than a bare `claude` argv.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||||
|
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||||
|
|
||||||
|
assert_eq!(cmd[0], "bash");
|
||||||
|
assert_eq!(cmd[1], "-c");
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(UPDATE_PRELUDE),
|
||||||
|
"plain path must run the update prelude: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||||
|
// The update has to happen *before* the exec, which never returns.
|
||||||
|
assert!(
|
||||||
|
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||||
|
"prelude must precede the exec: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||||
|
"the update must stay time-bounded and non-fatal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session name is interpolated into a shell script, so a quote in it
|
||||||
|
/// must not break out of its single-quoted argument.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||||
|
let cmd = build_claude_terminal_cmd(
|
||||||
|
&project("anthropic", serde_json::Value::Null),
|
||||||
|
None,
|
||||||
|
Some("Bob's tab; rm -rf /"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||||
|
"session name must be single-quote escaped: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission flags travel the same escaped path, and an empty name adds
|
||||||
|
/// no `-n` at all.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||||
|
let mut p = project("anthropic", serde_json::Value::Null);
|
||||||
|
p.full_permissions = true;
|
||||||
|
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||||
|
"got: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
||||||
|
"latest.log"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
host_upload_name("/home/u/Downloads/").is_err(),
|
||||||
|
"a directory is not a file to drop"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
host_upload_name("/home/u/..").is_err(),
|
||||||
|
"the name becomes a tar entry, a container path and an argv element"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
|
use serde::Deserialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::docker;
|
||||||
|
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
const RELEASES_URL: &str =
|
const RELEASES_URL: &str =
|
||||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
"https://api.github.com/repos/shadowdao/triple-c/releases";
|
||||||
|
|
||||||
|
/// GHCR container-registry API base (OCI distribution spec).
|
||||||
|
const REGISTRY_API_BASE: &str =
|
||||||
|
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
|
||||||
|
|
||||||
|
/// GHCR token endpoint for anonymous pull access.
|
||||||
|
const GHCR_TOKEN_URL: &str =
|
||||||
|
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||||
|
|
||||||
|
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||||
|
///
|
||||||
|
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||||
|
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||||
|
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||||
|
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||||
|
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||||
|
/// is the workaround: set as a build-time env var in the preview workflow
|
||||||
|
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||||
|
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||||
|
/// `None` here — see triple-c#32.
|
||||||
|
///
|
||||||
|
/// The single source of truth for "is this a preview build": both
|
||||||
|
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||||
|
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||||
|
/// read this rather than each calling `option_env!` themselves, so the two
|
||||||
|
/// can never silently disagree about which build this is.
|
||||||
|
fn preview_build_suffix() -> Option<&'static str> {
|
||||||
|
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||||
|
match build_suffix {
|
||||||
|
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||||
|
_ => base.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -15,9 +56,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
let releases: Vec<GiteaRelease> = client
|
let releases: Vec<GitHubRelease> = client
|
||||||
.get(RELEASES_URL)
|
.get(RELEASES_URL)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
||||||
@@ -26,40 +68,38 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION");
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
let is_windows = cfg!(target_os = "windows");
|
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
||||||
|
|
||||||
// Filter releases by platform tag suffix
|
// Determine platform-specific asset extensions
|
||||||
let platform_releases: Vec<&GiteaRelease> = releases
|
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
|
||||||
.iter()
|
&[".msi", ".exe"]
|
||||||
.filter(|r| {
|
} else if cfg!(target_os = "macos") {
|
||||||
if is_windows {
|
&[".dmg", ".app.tar.gz"]
|
||||||
r.tag_name.ends_with("-win")
|
|
||||||
} else {
|
} else {
|
||||||
!r.tag_name.ends_with("-win")
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
}
|
};
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Find the latest release with a higher patch version
|
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||||
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
|
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||||
let current_patch = parse_patch_version(current_version).unwrap_or(0);
|
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||||
|
// so a preview build and the release it precedes compile to the identical
|
||||||
|
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||||
|
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||||
|
// one release a preview most needs to be offered. `is_preview_build`
|
||||||
|
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||||
|
// own number" reads as an update, without touching the production case
|
||||||
|
// — see `pick_update`.
|
||||||
|
let is_preview_build = preview_build_suffix().is_some();
|
||||||
|
|
||||||
let mut best: Option<(&GiteaRelease, u32)> = None;
|
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||||
for release in &platform_releases {
|
Some(release) => {
|
||||||
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
|
// Only include assets matching the current platform
|
||||||
if patch > current_patch {
|
|
||||||
if best.is_none() || patch > best.unwrap().1 {
|
|
||||||
best = Some((release, patch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match best {
|
|
||||||
Some((release, _)) => {
|
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||||
|
})
|
||||||
.map(|a| ReleaseAsset {
|
.map(|a| ReleaseAsset {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
browser_download_url: a.browser_download_url.clone(),
|
browser_download_url: a.browser_download_url.clone(),
|
||||||
@@ -67,7 +107,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Reconstruct version string from tag
|
|
||||||
let version = extract_version_from_tag(&release.tag_name)
|
let version = extract_version_from_tag(&release.tag_name)
|
||||||
.unwrap_or_else(|| release.tag_name.clone());
|
.unwrap_or_else(|| release.tag_name.clone());
|
||||||
|
|
||||||
@@ -84,34 +123,311 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a semver string like "0.1.5" -> 5
|
/// Pick the newest available update out of a release list, or `None` if
|
||||||
fn parse_patch_version(version: &str) -> Option<u32> {
|
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||||
|
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||||
|
/// tested without a live HTTP call.
|
||||||
|
///
|
||||||
|
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||||
|
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||||
|
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||||
|
/// tag that does not parse — `preview-<sha>` (the shape
|
||||||
|
/// `build-app-preview.yml` actually creates release tags with), most
|
||||||
|
/// realistically — is skipped rather than erroring, the same as it always
|
||||||
|
/// has been; nothing here changes what an update tag is expected to look
|
||||||
|
/// like, only what channel it is allowed to come from.
|
||||||
|
///
|
||||||
|
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||||
|
/// `current_semver` is the bare number it was compiled with, which is by
|
||||||
|
/// construction identical to the release it precedes — see the comment at
|
||||||
|
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||||
|
/// exactly the release a preview install most needs to be told about.
|
||||||
|
fn pick_update<'a>(
|
||||||
|
releases: &'a [GitHubRelease],
|
||||||
|
current_semver: (u32, u32, u32),
|
||||||
|
platform_extensions: &[&str],
|
||||||
|
is_preview_build: bool,
|
||||||
|
) -> Option<&'a GitHubRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.prerelease)
|
||||||
|
.filter(|r| {
|
||||||
|
r.assets
|
||||||
|
.iter()
|
||||||
|
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||||
|
})
|
||||||
|
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||||
|
.filter(|(_, ver)| {
|
||||||
|
if is_preview_build {
|
||||||
|
*ver >= current_semver
|
||||||
|
} else {
|
||||||
|
*ver > current_semver
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max_by_key(|(_, ver)| *ver)
|
||||||
|
.map(|(r, _)| r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
let clean = version.trim_start_matches('v');
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
let parts: Vec<&str> = clean.split('.').collect();
|
||||||
if parts.len() >= 3 {
|
if parts.len() >= 3 {
|
||||||
parts[2].parse().ok()
|
let major = parts[0].parse().ok()?;
|
||||||
|
let minor = parts[1].parse().ok()?;
|
||||||
|
let patch = parts[2].parse().ok()?;
|
||||||
|
Some((major, minor, patch))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
|
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
|
||||||
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
|
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let clean = tag.trim_start_matches('v');
|
||||||
// Remove platform suffix
|
parse_semver(clean)
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
|
||||||
parse_patch_version(clean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
|
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
|
||||||
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
Some(format!("{}.{}.{}", major, minor, patch))
|
||||||
// Validate it looks like a version
|
}
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
|
||||||
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
|
#[cfg(test)]
|
||||||
Some(clean.to_string())
|
mod tests {
|
||||||
} else {
|
use super::*;
|
||||||
None
|
use crate::models::GitHubAsset;
|
||||||
|
|
||||||
|
// ── format_app_version ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_production_build_reports_the_bare_version() {
|
||||||
|
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||||
|
// An empty env var (set but blank) must not print a trailing dash.
|
||||||
|
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_reports_its_suffix() {
|
||||||
|
assert_eq!(
|
||||||
|
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||||
|
"0.4.12-preview.a1b2c3d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pick_update ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||||
|
GitHubRelease {
|
||||||
|
tag_name: tag.to_string(),
|
||||||
|
html_url: format!("https://example.invalid/{}", tag),
|
||||||
|
body: String::new(),
|
||||||
|
assets: asset_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| GitHubAsset {
|
||||||
|
name: name.to_string(),
|
||||||
|
browser_download_url: String::new(),
|
||||||
|
size: 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
prerelease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||||
|
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_that_is_not_newer_is_not_offered() {
|
||||||
|
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||||
|
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||||
|
// or mistake for an update — it simply never parses as a bare semver.
|
||||||
|
let releases = vec![
|
||||||
|
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.12", false, &["app.AppImage"]),
|
||||||
|
];
|
||||||
|
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||||
|
let releases = vec![
|
||||||
|
release("v0.4.11", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.13", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.12", false, &["app.AppImage"]),
|
||||||
|
];
|
||||||
|
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.13");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||||
|
|
||||||
|
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||||
|
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||||
|
/// offered the `v0.4.12` release that follows it, even though the two
|
||||||
|
/// compute to the identical numeric tuple.
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_is_not_offered_an_older_release() {
|
||||||
|
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_production_build_still_requires_strictly_newer() {
|
||||||
|
// A production build must never treat "equal" as an update — that
|
||||||
|
// would perpetually re-offer the version already running.
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether a newer container image is available in the registry.
|
||||||
|
///
|
||||||
|
/// Compares the local image digest with the remote registry digest using the
|
||||||
|
/// Docker Registry HTTP API v2. Only applies when the image source is
|
||||||
|
/// "registry" (the default); for local builds or custom images we cannot
|
||||||
|
/// meaningfully check for remote updates.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_image_update(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<ImageUpdateInfo>, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
|
||||||
|
// Only check for registry images
|
||||||
|
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_name =
|
||||||
|
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
|
// 1. Get local image digest via Docker
|
||||||
|
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
||||||
|
|
||||||
|
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
|
||||||
|
let remote_digest = fetch_remote_digest("latest").await?;
|
||||||
|
|
||||||
|
// No remote digest available — nothing to compare
|
||||||
|
let remote_digest = match remote_digest {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If local digest matches remote, no update
|
||||||
|
if let Some(ref local) = local_digest {
|
||||||
|
if *local == remote_digest {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There's a difference (or no local image at all)
|
||||||
|
Ok(Some(ImageUpdateInfo {
|
||||||
|
remote_digest,
|
||||||
|
local_digest,
|
||||||
|
remote_updated_at: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
|
||||||
|
///
|
||||||
|
/// GHCR requires authentication even for public images, so we first obtain an
|
||||||
|
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
|
||||||
|
/// and read the `Docker-Content-Digest` header.
|
||||||
|
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
// 1. Obtain anonymous bearer token from GHCR
|
||||||
|
let token = match fetch_ghcr_token(&client).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to obtain GHCR token: {}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. HEAD the manifest with the token
|
||||||
|
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.head(&url)
|
||||||
|
.header(
|
||||||
|
"Accept",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
||||||
|
)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) => {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
log::warn!(
|
||||||
|
"Registry returned status {} when checking image digest",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// The digest is returned in the Docker-Content-Digest header
|
||||||
|
if let Some(digest) = resp.headers().get("docker-content-digest") {
|
||||||
|
if let Ok(val) = digest.to_str() {
|
||||||
|
return Ok(Some(val.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to check registry for image update: {}", e);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch an anonymous bearer token from GHCR for pulling public images.
|
||||||
|
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp: TokenResponse = client
|
||||||
|
.get(GHCR_TOKEN_URL)
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("GHCR token request failed: {}", e))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(resp.token)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::web_terminal::WebTerminalServer;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WebTerminalInfo {
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub access_token: String,
|
||||||
|
pub local_ip: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_local_ip() -> Option<String> {
|
||||||
|
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
|
||||||
|
let local_ip = get_local_ip();
|
||||||
|
let url = if running {
|
||||||
|
local_ip
|
||||||
|
.as_ref()
|
||||||
|
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
WebTerminalInfo {
|
||||||
|
running,
|
||||||
|
port,
|
||||||
|
access_token: token.to_string(),
|
||||||
|
local_ip,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if server_guard.is_some() {
|
||||||
|
return Err("Web terminal server is already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
|
||||||
|
// Auto-generate token if not set
|
||||||
|
if settings.web_terminal.access_token.is_none() {
|
||||||
|
settings.web_terminal.access_token = Some(generate_token());
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let port = settings.web_terminal.port;
|
||||||
|
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
port,
|
||||||
|
token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
*server_guard = Some(server);
|
||||||
|
|
||||||
|
// Mark as enabled in settings
|
||||||
|
if !settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(true, port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as disabled in settings
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = false;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let server_guard = state.web_terminal_server.lock().await;
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let running = server_guard.is_some();
|
||||||
|
Ok(build_info(running, settings.web_terminal.port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
// Stop current server if running
|
||||||
|
{
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token and save
|
||||||
|
let new_token = generate_token();
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
settings.web_terminal.access_token = Some(new_token.clone());
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
|
// Restart if was enabled
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
settings.web_terminal.port,
|
||||||
|
new_token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
*server_guard = Some(server);
|
||||||
|
return Ok(build_info(true, settings.web_terminal.port, &new_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(false, settings.web_terminal.port, &new_token))
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
//! Corporate CA certificate injection.
|
||||||
|
//!
|
||||||
|
//! Users behind a TLS-terminating corporate proxy need their organisation's
|
||||||
|
//! root CA inside every container, or **every** HTTPS call fails — npm, pip,
|
||||||
|
//! git, curl, the Playwright browser, and Claude Code's own API calls.
|
||||||
|
//!
|
||||||
|
//! The mechanism follows the SSH/AWS host-mount pattern in [`super::container`]:
|
||||||
|
//! a host path is bind-mounted **read-only** into the container and
|
||||||
|
//! `entrypoint.sh` applies it on every start. That is what makes it durable
|
||||||
|
//! across container recreation, base-image migration and Reset — a certificate
|
||||||
|
//! installed by hand inside a running container is lost the first time any of
|
||||||
|
//! those happen.
|
||||||
|
//!
|
||||||
|
//! ## Two things that are easy to get wrong
|
||||||
|
//!
|
||||||
|
//! 1. **`update-ca-certificates` only reads `*.crt`.** It globs
|
||||||
|
//! `/usr/local/share/ca-certificates/*.crt` case-sensitively, so a `.pem`
|
||||||
|
//! (the far more common export format) that is merely *copied* in is
|
||||||
|
//! silently ignored — no warning, no error, just a container that still
|
||||||
|
//! cannot speak HTTPS. Certificates must be **renamed**, which is what
|
||||||
|
//! [`container_cert_name`] does.
|
||||||
|
//!
|
||||||
|
//! 2. **The system trust store is not enough.** Only curl/git/apt read it.
|
||||||
|
//! Node — and therefore Claude Code itself — needs `NODE_EXTRA_CA_CERTS`,
|
||||||
|
//! Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`, and
|
||||||
|
//! Chrome/Chromium read neither: they have their own NSS database at
|
||||||
|
//! `~/.pki/nssdb`, seeded by `certutil` in the entrypoint.
|
||||||
|
//!
|
||||||
|
//! ## Why the env vars are set from Rust and not exported by the entrypoint
|
||||||
|
//!
|
||||||
|
//! An `export` in `entrypoint.sh` reaches only the entrypoint's own children.
|
||||||
|
//! Every terminal session is a separate `docker exec`, which inherits the
|
||||||
|
//! *container's* configured env and sees nothing the entrypoint exported —
|
||||||
|
//! the same lesson that forced `$BROWSER` to become an image-level `ENV` for
|
||||||
|
//! the URL relay shim. Since the bundle path written by
|
||||||
|
//! `update-ca-certificates` is deterministic ([`CA_BUNDLE_PATH`]), Rust can set
|
||||||
|
//! all three vars at container creation, where `docker exec` will see them.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Where the host's CA material is bind-mounted, read-only. Mirrors
|
||||||
|
/// `/tmp/.host-ssh` and `/tmp/.host-aws`.
|
||||||
|
///
|
||||||
|
/// A *directory* on the host is mounted here as-is. A single *file* is mounted
|
||||||
|
/// at `<CA_MOUNT_DIR>/<normalised name>` — Docker creates the parent — so the
|
||||||
|
/// entrypoint only ever has to deal with a directory, and the certificate keeps
|
||||||
|
/// a recognisable name instead of becoming the literal path `.host-ca`.
|
||||||
|
pub const CA_MOUNT_DIR: &str = "/tmp/.host-ca";
|
||||||
|
|
||||||
|
/// The concatenated PEM bundle `update-ca-certificates` writes on
|
||||||
|
/// Debian/Ubuntu. Deterministic, which is what lets the env vars below be set
|
||||||
|
/// at container-creation time, before the entrypoint has run.
|
||||||
|
pub const CA_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt";
|
||||||
|
|
||||||
|
/// Consulted by Node — and therefore by Claude Code itself, which is the whole
|
||||||
|
/// reason this feature exists.
|
||||||
|
pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS";
|
||||||
|
/// Consulted by `requests` (and so by pip's vendored copy).
|
||||||
|
pub const REQUESTS_CA_BUNDLE: &str = "REQUESTS_CA_BUNDLE";
|
||||||
|
/// Consulted by OpenSSL, and so by Python's `ssl` module.
|
||||||
|
pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE";
|
||||||
|
|
||||||
|
/// Every env var this module owns, in a fixed order.
|
||||||
|
///
|
||||||
|
/// Also the list that must be *cleared* when no CA is configured: `docker
|
||||||
|
/// commit` bakes a container's env into the project's snapshot image, and
|
||||||
|
/// create-time env replaces image `ENV` per key — so without an explicit empty
|
||||||
|
/// value, removing the setting would leave the vars live in every future
|
||||||
|
/// container. Empty is safe for all three (verified on Ubuntu 24.04: curl,
|
||||||
|
/// `openssl s_client` and Python's `ssl` all behave exactly as they do with the
|
||||||
|
/// variable unset).
|
||||||
|
pub const CA_ENV_KEYS: &[&str] = &[NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE];
|
||||||
|
|
||||||
|
/// Extensions treated as certificates when the configured path is a directory.
|
||||||
|
/// Matched case-insensitively. DER is deliberately absent — the system store
|
||||||
|
/// and every consumer here want PEM.
|
||||||
|
const CERT_EXTENSIONS: &[&str] = &["crt", "pem", "cer", "cert", "ca-bundle"];
|
||||||
|
|
||||||
|
/// A configured CA path that has been checked and resolved into everything the
|
||||||
|
/// container creation path needs.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedCa {
|
||||||
|
/// The host path, as configured.
|
||||||
|
pub host_path: String,
|
||||||
|
/// Whether the host path is a directory (as opposed to a single file).
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The bind-mount target inside the container.
|
||||||
|
pub mount_target: String,
|
||||||
|
/// The certificate files found, sorted.
|
||||||
|
pub cert_files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name a certificate is installed as under
|
||||||
|
/// `/usr/local/share/ca-certificates/`.
|
||||||
|
///
|
||||||
|
/// `update-ca-certificates` globs `*.crt` **case-sensitively**, so `.pem`,
|
||||||
|
/// `.cer`, `.CRT` and extension-less files all have to end up as a lowercase
|
||||||
|
/// `.crt` or they are ignored without a word. Characters outside
|
||||||
|
/// `[A-Za-z0-9._-]` are replaced so that whitespace cannot break the shell
|
||||||
|
/// loops that walk the store, and leading dots are stripped so a hidden file
|
||||||
|
/// does not stay hidden.
|
||||||
|
///
|
||||||
|
/// `entrypoint.sh` reimplements exactly this in a few lines of shell (it has to
|
||||||
|
/// rename the files inside the container); the two must agree, which is what
|
||||||
|
/// the unit tests below pin down.
|
||||||
|
pub fn container_cert_name(file_name: &str) -> String {
|
||||||
|
let sanitized: String = file_name
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sanitized = sanitized.trim_start_matches('.');
|
||||||
|
// Strip one trailing extension, whatever it is, then force `.crt`. A name
|
||||||
|
// with no dot keeps its whole self as the stem.
|
||||||
|
let stem = match sanitized.rfind('.') {
|
||||||
|
Some(i) => &sanitized[..i],
|
||||||
|
None => sanitized,
|
||||||
|
};
|
||||||
|
let stem = if stem.is_empty() { "corporate-ca" } else { stem };
|
||||||
|
format!("{}.crt", stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a directory entry looks like a certificate worth installing.
|
||||||
|
fn is_cert_file(path: &Path) -> bool {
|
||||||
|
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ext = ext.to_ascii_lowercase();
|
||||||
|
CERT_EXTENSIONS.contains(&ext.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The certificate files a configured path contributes.
|
||||||
|
///
|
||||||
|
/// A file is taken at face value — the user pointed at it explicitly, so its
|
||||||
|
/// extension is not second-guessed. A directory is scanned one level deep
|
||||||
|
/// (matching the entrypoint's `find -maxdepth 1`) and filtered by extension,
|
||||||
|
/// so an `openssl.cnf` or a README sitting next to the certs is skipped.
|
||||||
|
/// The result is sorted, so the fingerprint is stable across filesystem
|
||||||
|
/// enumeration order.
|
||||||
|
pub fn collect_cert_files(path: &Path) -> Vec<PathBuf> {
|
||||||
|
if path.is_file() {
|
||||||
|
return vec![path.to_path_buf()];
|
||||||
|
}
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut files: Vec<PathBuf> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.is_file() && is_cert_file(p))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the configured CA path, or explain why it cannot be used.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` means "no CA configured", which is the overwhelmingly common
|
||||||
|
/// case and must stay free. An `Err` aborts the container start: behind a
|
||||||
|
/// TLS-intercepting proxy a container without the CA is broken in a dozen
|
||||||
|
/// confusing ways, so naming the bad path once is far kinder than letting npm,
|
||||||
|
/// pip and Claude Code each fail their own way.
|
||||||
|
pub fn resolve(path: Option<&str>) -> Result<Option<ResolvedCa>, String> {
|
||||||
|
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let root = Path::new(raw);
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate path '{}' does not exist. Update it in \
|
||||||
|
Settings → Certificates, or clear this project's override in \
|
||||||
|
Project Home → Config → Access.",
|
||||||
|
raw
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = root.is_dir();
|
||||||
|
if !is_dir && !root.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate path '{}' is neither a file nor a directory.",
|
||||||
|
raw
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cert_files = collect_cert_files(root);
|
||||||
|
if cert_files.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate directory '{}' contains no certificate files \
|
||||||
|
(looked for {} one level deep).",
|
||||||
|
raw,
|
||||||
|
CERT_EXTENSIONS
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!(".{}", e))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mount_target = if is_dir {
|
||||||
|
CA_MOUNT_DIR.to_string()
|
||||||
|
} else {
|
||||||
|
let name = root
|
||||||
|
.file_name()
|
||||||
|
.map(|n| container_cert_name(&n.to_string_lossy()))
|
||||||
|
.unwrap_or_else(|| "corporate-ca.crt".to_string());
|
||||||
|
format!("{}/{}", CA_MOUNT_DIR, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ResolvedCa {
|
||||||
|
host_path: raw.to_string(),
|
||||||
|
is_dir,
|
||||||
|
mount_target,
|
||||||
|
cert_files,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the CA configuration, for the `triple-c.ca-fingerprint`
|
||||||
|
/// label.
|
||||||
|
///
|
||||||
|
/// `container_needs_recreation` is label-based and never diffs env or mounts,
|
||||||
|
/// so without this, changing the CA path would silently do nothing until some
|
||||||
|
/// unrelated setting forced a rebuild.
|
||||||
|
///
|
||||||
|
/// It covers **both** the resolved path *and the bytes of every certificate*,
|
||||||
|
/// because replacing a rotated CA at the same path is at least as common as
|
||||||
|
/// moving it — and the container's copy is made once, at start, so nothing else
|
||||||
|
/// would notice.
|
||||||
|
///
|
||||||
|
/// Never returns an error: a path that has gone missing hashes differently from
|
||||||
|
/// one that is present, which is exactly the "something changed, recreate"
|
||||||
|
/// signal wanted here. Reporting the problem is [`resolve`]'s job.
|
||||||
|
pub fn compute_ca_fingerprint(path: Option<&str>) -> String {
|
||||||
|
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut parts: Vec<String> = vec![raw.to_string()];
|
||||||
|
let root = Path::new(raw);
|
||||||
|
if !root.exists() {
|
||||||
|
parts.push("<missing>".to_string());
|
||||||
|
} else {
|
||||||
|
for file in collect_cert_files(root) {
|
||||||
|
let name = file
|
||||||
|
.file_name()
|
||||||
|
.map(|n| container_cert_name(&n.to_string_lossy()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let digest = match std::fs::read(&file) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
Err(_) => "<unreadable>".to_string(),
|
||||||
|
};
|
||||||
|
parts.push(format!("{}:{}", name, digest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sha256_hex(&parts.join("|"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The env vars to set on the container.
|
||||||
|
///
|
||||||
|
/// Always returns all of [`CA_ENV_KEYS`]: pointing at the bundle when a CA is
|
||||||
|
/// configured, empty when it is not. The empty case is not cosmetic — see the
|
||||||
|
/// note on [`CA_ENV_KEYS`].
|
||||||
|
pub fn ca_env_vars(resolved: Option<&ResolvedCa>) -> Vec<(&'static str, String)> {
|
||||||
|
let value = if resolved.is_some() { CA_BUNDLE_PATH } else { "" };
|
||||||
|
CA_ENV_KEYS
|
||||||
|
.iter()
|
||||||
|
.map(|key| (*key, value.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// A scratch directory that cleans itself up. `tempfile` is not a
|
||||||
|
/// dependency of this crate and this is the only test that needs one.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new(tag: &str) -> Self {
|
||||||
|
let mut p = std::env::temp_dir();
|
||||||
|
p.push(format!(
|
||||||
|
"triple-c-ca-test-{}-{}-{:?}",
|
||||||
|
tag,
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&p).unwrap();
|
||||||
|
TempDir(p)
|
||||||
|
}
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
fn write(&self, name: &str, contents: &str) -> PathBuf {
|
||||||
|
let p = self.0.join(name);
|
||||||
|
fs::write(&p, contents).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── container_cert_name ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pem_is_renamed_to_crt_not_merely_copied() {
|
||||||
|
// The whole point: update-ca-certificates globs *.crt and would
|
||||||
|
// silently ignore corp-root.pem.
|
||||||
|
assert_eq!(container_cert_name("corp-root.pem"), "corp-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crt_keeps_its_name() {
|
||||||
|
assert_eq!(container_cert_name("corp-root.crt"), "corp-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_certificate_extensions_are_renamed_too() {
|
||||||
|
assert_eq!(container_cert_name("zscaler.cer"), "zscaler.crt");
|
||||||
|
assert_eq!(container_cert_name("zscaler.cert"), "zscaler.crt");
|
||||||
|
assert_eq!(container_cert_name("bundle.ca-bundle"), "bundle.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uppercase_extension_is_lowercased() {
|
||||||
|
// `find -name '*.crt'` is case-sensitive, so CA.CRT would be ignored.
|
||||||
|
assert_eq!(container_cert_name("CA.CRT"), "CA.crt");
|
||||||
|
assert_eq!(container_cert_name("CA.PEM"), "CA.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_without_an_extension_gains_one() {
|
||||||
|
assert_eq!(container_cert_name("corporate-root"), "corporate-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_extension_is_replaced() {
|
||||||
|
assert_eq!(container_cert_name("corp.root.ca.pem"), "corp.root.ca.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_characters_are_replaced() {
|
||||||
|
assert_eq!(
|
||||||
|
container_cert_name("Corp Root CA (2026).pem"),
|
||||||
|
"Corp_Root_CA__2026_.crt"
|
||||||
|
);
|
||||||
|
assert_eq!(container_cert_name("a/b.pem"), "a_b.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_dots_are_stripped_so_the_file_is_not_hidden() {
|
||||||
|
assert_eq!(container_cert_name(".hidden.pem"), "hidden.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_degenerate_name_still_produces_a_usable_file() {
|
||||||
|
assert_eq!(container_cert_name(".pem"), "pem.crt");
|
||||||
|
assert_eq!(container_cert_name(""), "corporate-ca.crt");
|
||||||
|
assert_eq!(container_cert_name("..."), "corporate-ca.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_produced_name_ends_in_lowercase_crt() {
|
||||||
|
for input in [
|
||||||
|
"a.pem", "b.CRT", "c", ".d.pem", "", "e f.cer", "...", "ç.pem",
|
||||||
|
] {
|
||||||
|
let out = container_cert_name(input);
|
||||||
|
assert!(
|
||||||
|
out.ends_with(".crt"),
|
||||||
|
"{:?} produced {:?}, which update-ca-certificates would ignore",
|
||||||
|
input,
|
||||||
|
out
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
|
||||||
|
"{:?} produced {:?}, which is not shell-safe",
|
||||||
|
input,
|
||||||
|
out
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fingerprint ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_configured_path_fingerprints_as_empty() {
|
||||||
|
assert_eq!(compute_ca_fingerprint(None), "");
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some("")), "");
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some(" ")), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_the_path_changes_the_fingerprint() {
|
||||||
|
let a = TempDir::new("path-a");
|
||||||
|
let b = TempDir::new("path-b");
|
||||||
|
// Identical *content* in both, so only the path differs.
|
||||||
|
a.write("corp.pem", "CERT-BODY");
|
||||||
|
b.write("corp.pem", "CERT-BODY");
|
||||||
|
|
||||||
|
let fp_a = compute_ca_fingerprint(Some(a.path().to_str().unwrap()));
|
||||||
|
let fp_b = compute_ca_fingerprint(Some(b.path().to_str().unwrap()));
|
||||||
|
assert_ne!(fp_a, "");
|
||||||
|
assert_ne!(
|
||||||
|
fp_a, fp_b,
|
||||||
|
"two different paths must not share a fingerprint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_the_certificate_content_at_the_same_path_changes_the_fingerprint() {
|
||||||
|
// The case a path-only fingerprint would miss: the corporate CA is
|
||||||
|
// rotated and the new one dropped in at exactly the same location.
|
||||||
|
let dir = TempDir::new("rotate");
|
||||||
|
dir.write("corp.pem", "OLD-CERT");
|
||||||
|
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
dir.write("corp.pem", "NEW-CERT");
|
||||||
|
let after = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
before, after,
|
||||||
|
"replacing the certificate at the same path must force a recreation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adding_or_removing_a_certificate_changes_the_fingerprint() {
|
||||||
|
let dir = TempDir::new("add");
|
||||||
|
dir.write("one.pem", "A");
|
||||||
|
let one = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
dir.write("two.pem", "B");
|
||||||
|
let two = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
assert_ne!(one, two);
|
||||||
|
fs::remove_file(dir.path().join("two.pem")).unwrap();
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), one);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_directory_fingerprints_identically() {
|
||||||
|
let dir = TempDir::new("stable");
|
||||||
|
dir.write("corp.pem", "SAME");
|
||||||
|
let a = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
let b = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
assert_eq!(a, b, "the fingerprint must not churn on repeated reads");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_path_fingerprints_differently_from_a_present_one() {
|
||||||
|
let dir = TempDir::new("missing");
|
||||||
|
let present = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
let missing =
|
||||||
|
compute_ca_fingerprint(Some(&format!("{}-gone", dir.path().to_str().unwrap())));
|
||||||
|
assert_ne!(present, missing);
|
||||||
|
assert_ne!(missing, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_certificate_files_in_the_directory_are_ignored() {
|
||||||
|
let dir = TempDir::new("noise");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
dir.write("README.md", "hello");
|
||||||
|
dir.write("openssl.cnf", "[req]");
|
||||||
|
assert_eq!(
|
||||||
|
compute_ca_fingerprint(Some(dir.path().to_str().unwrap())),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_path_resolves_to_nothing() {
|
||||||
|
assert_eq!(resolve(None).unwrap(), None);
|
||||||
|
assert_eq!(resolve(Some(" ")).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_path_is_an_actionable_error() {
|
||||||
|
let err = resolve(Some("/definitely/not/here/corp.pem")).unwrap_err();
|
||||||
|
assert!(err.contains("/definitely/not/here/corp.pem"), "{}", err);
|
||||||
|
assert!(err.contains("Settings"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_directory_is_an_actionable_error() {
|
||||||
|
let dir = TempDir::new("empty");
|
||||||
|
let err = resolve(Some(dir.path().to_str().unwrap())).unwrap_err();
|
||||||
|
assert!(err.contains("no certificate files"), "{}", err);
|
||||||
|
assert!(err.contains(".pem"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_mounts_at_the_shared_mount_point() {
|
||||||
|
let dir = TempDir::new("dir");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert!(resolved.is_dir);
|
||||||
|
assert_eq!(resolved.mount_target, CA_MOUNT_DIR);
|
||||||
|
assert_eq!(resolved.cert_files.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_file_mounts_under_the_mount_point_with_a_crt_name() {
|
||||||
|
// Mounting a file *at* /tmp/.host-ca would leave the entrypoint with no
|
||||||
|
// name to work from, and would make the mount point a file rather than
|
||||||
|
// the directory the entrypoint expects.
|
||||||
|
let dir = TempDir::new("file");
|
||||||
|
let file = dir.write("corp root.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert!(!resolved.is_dir);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.mount_target,
|
||||||
|
format!("{}/corp_root.crt", CA_MOUNT_DIR)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_is_accepted_whatever_its_extension() {
|
||||||
|
// The user pointed at it explicitly; don't second-guess.
|
||||||
|
let dir = TempDir::new("odd-ext");
|
||||||
|
let file = dir.write("corp.txt", "CERT");
|
||||||
|
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert_eq!(resolved.cert_files, vec![file]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── env vars ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_ca_points_every_consumer_at_the_bundle() {
|
||||||
|
let dir = TempDir::new("env");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap();
|
||||||
|
let vars = ca_env_vars(resolved.as_ref());
|
||||||
|
assert_eq!(
|
||||||
|
vars,
|
||||||
|
vec![
|
||||||
|
(NODE_EXTRA_CA_CERTS, CA_BUNDLE_PATH.to_string()),
|
||||||
|
(REQUESTS_CA_BUNDLE, CA_BUNDLE_PATH.to_string()),
|
||||||
|
(SSL_CERT_FILE, CA_BUNDLE_PATH.to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ca_clears_every_var_rather_than_omitting_it() {
|
||||||
|
// Omitting them would let a value baked into the project's snapshot
|
||||||
|
// image survive the setting being turned off.
|
||||||
|
let vars = ca_env_vars(None);
|
||||||
|
assert_eq!(vars.len(), CA_ENV_KEYS.len());
|
||||||
|
assert!(vars.iter().all(|(_, v)| v.is_empty()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
use std::sync::OnceLock;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
|
||||||
|
|
||||||
pub fn get_docker() -> Result<&'static Docker, String> {
|
pub fn get_docker() -> Result<Docker, String> {
|
||||||
let result = DOCKER.get_or_init(|| {
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
Docker::connect_with_local_defaults()
|
if let Some(docker) = guard.as_ref() {
|
||||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
|
return Ok(docker.clone());
|
||||||
});
|
|
||||||
match result {
|
|
||||||
Ok(docker) => Ok(docker),
|
|
||||||
Err(e) => Err(e.clone()),
|
|
||||||
}
|
}
|
||||||
|
let docker = Docker::connect_with_local_defaults()
|
||||||
|
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
|
||||||
|
guard.replace(docker.clone());
|
||||||
|
Ok(docker)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_docker_available() -> Result<bool, String> {
|
pub async fn check_docker_available() -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
match docker.ping().await {
|
match docker.ping().await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
|
Err(_) => {
|
||||||
|
// Connection object exists but daemon not responding — clear cache
|
||||||
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
|
*guard = None;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,94 @@
|
|||||||
|
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||||
|
/// attached — the raw duplex halves, before any policy about what to do with
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// This is the single place in the codebase that knows how to open an attached
|
||||||
|
/// exec. Both consumers are built on it:
|
||||||
|
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||||
|
/// which pump bytes through mpsc channels and a callback.
|
||||||
|
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||||
|
/// straight between a host TCP socket and these halves.
|
||||||
|
///
|
||||||
|
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||||
|
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||||
|
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||||
|
/// spliced into the proxied byte stream.
|
||||||
|
pub struct AttachedExec {
|
||||||
|
pub exec_id: String,
|
||||||
|
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||||
|
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||||
|
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||||
|
/// this app opens.
|
||||||
|
pub async fn create_attached_exec(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||||
|
///
|
||||||
|
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||||
|
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||||
|
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||||
|
/// stays the single place an attached exec is opened.
|
||||||
|
pub async fn create_attached_exec_as(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
user: &str,
|
||||||
|
working_dir: &str,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdin: Some(true),
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
tty: Some(tty),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some(user.to_string()),
|
||||||
|
working_dir: Some(working_dir.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let exec_id = exec.id.clone();
|
||||||
|
|
||||||
|
match docker
|
||||||
|
.start_exec(&exec_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?
|
||||||
|
{
|
||||||
|
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
}),
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
@@ -21,6 +103,7 @@ impl ExecSession {
|
|||||||
.map_err(|e| format!("Failed to send input: {}", e))
|
.map_err(|e| format!("Failed to send input: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
docker
|
docker
|
||||||
@@ -62,37 +145,31 @@ impl ExecSessionManager {
|
|||||||
where
|
where
|
||||||
F: Fn(Vec<u8>) + Send + 'static,
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
{
|
{
|
||||||
let docker = get_docker()?;
|
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||||
|
|
||||||
let exec = docker
|
|
||||||
.create_exec(
|
|
||||||
container_id,
|
|
||||||
CreateExecOptions {
|
|
||||||
attach_stdin: Some(true),
|
|
||||||
attach_stdout: Some(true),
|
|
||||||
attach_stderr: Some(true),
|
|
||||||
tty: Some(true),
|
|
||||||
cmd: Some(cmd),
|
|
||||||
user: Some("claude".to_string()),
|
|
||||||
working_dir: Some("/workspace".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
}
|
||||||
|
|
||||||
let exec_id = exec.id.clone();
|
pub async fn create_session_with_tty<F>(
|
||||||
|
&self,
|
||||||
let result = docker
|
container_id: &str,
|
||||||
.start_exec(&exec_id, None)
|
session_id: &str,
|
||||||
.await
|
cmd: Vec<String>,
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
tty: bool,
|
||||||
|
on_output: F,
|
||||||
|
on_exit: Box<dyn FnOnce() + Send>,
|
||||||
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
|
{
|
||||||
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||||
|
|
||||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
match result {
|
|
||||||
StartExecResults::Attached { mut output, mut input } => {
|
|
||||||
// Output reader task
|
// Output reader task
|
||||||
let session_id_clone = session_id.to_string();
|
let session_id_clone = session_id.to_string();
|
||||||
let shutdown_tx_clone = shutdown_tx.clone();
|
let shutdown_tx_clone = shutdown_tx.clone();
|
||||||
@@ -133,11 +210,6 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
StartExecResults::Detached => {
|
|
||||||
return Err("Exec started in detached mode".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
@@ -212,4 +284,681 @@ impl ExecSessionManager {
|
|||||||
session.shutdown();
|
session.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
Ok(session.container_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_file_to_container(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Owned by the container user, stamped now: a default tar header would
|
||||||
|
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||||
|
let (uid, gid) = container_user_ids(container_id).await;
|
||||||
|
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: "/tmp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("/tmp/{}", file_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on one host file packed into a container upload.
|
||||||
|
///
|
||||||
|
/// The file goes through host RAM twice — once as bytes, once inside the tar —
|
||||||
|
/// so this is a memory bound, and it is checked against the *descriptor* that
|
||||||
|
/// was opened rather than a `metadata` call that described whatever the path
|
||||||
|
/// meant a moment earlier.
|
||||||
|
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||||
|
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||||
|
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||||
|
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||||
|
/// a size check and the read can't desync the header and corrupt the archive.
|
||||||
|
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||||
|
///
|
||||||
|
/// `dest_dir` must already exist and must already have been checked by the
|
||||||
|
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||||
|
/// callers both do that first, by different routes because they are answering
|
||||||
|
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||||
|
/// creates itself, and the Files pane passes the directory the user is looking
|
||||||
|
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||||
|
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||||
|
pub async fn upload_host_file_to_container(
|
||||||
|
container_id: &str,
|
||||||
|
host_path: &str,
|
||||||
|
dest_dir: &str,
|
||||||
|
dest_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let ids = container_user_ids(container_id).await;
|
||||||
|
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||||
|
/// container user's ids.
|
||||||
|
///
|
||||||
|
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||||
|
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||||
|
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||||
|
/// which is seconds of latency for a fact that cannot change inside one
|
||||||
|
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||||
|
/// The wrapper above keeps the single-file callers unchanged.
|
||||||
|
pub async fn upload_host_file_with_ids(
|
||||||
|
container_id: &str,
|
||||||
|
host_path: &str,
|
||||||
|
dest_dir: &str,
|
||||||
|
dest_name: &str,
|
||||||
|
(uid, gid): (u64, u64),
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let host_path = host_path.to_string();
|
||||||
|
let dest_name = dest_name.to_string();
|
||||||
|
let dest_for_blk = dest_name.clone();
|
||||||
|
let mtime = now_epoch_secs();
|
||||||
|
|
||||||
|
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
|
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||||
|
// is a second trip through the same directories, so the descriptor is
|
||||||
|
// checked against the path that was validated before its bytes are
|
||||||
|
// packed into anything. Two paths reach here: the terminal's drop
|
||||||
|
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||||
|
// Between them they are how host bytes enter a container.
|
||||||
|
let file = std::fs::File::open(&host_path)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
crate::commands::file_commands::verify_opened_path(
|
||||||
|
&file,
|
||||||
|
std::path::Path::new(&host_path),
|
||||||
|
)?;
|
||||||
|
let mut data = Vec::new();
|
||||||
|
std::io::Read::read_to_end(
|
||||||
|
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||||
|
&mut data,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
if data.len() as u64 > MAX_DROP_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"File too large to upload (limit {} MB)",
|
||||||
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: dest_dir.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(container_join(dest_dir, &dest_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a container directory to a name that may itself carry separators.
|
||||||
|
///
|
||||||
|
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||||
|
/// called — but that path is what the terminal echoes and what the Files pane
|
||||||
|
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||||
|
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||||
|
fn container_join(dir: &str, name: &str) -> String {
|
||||||
|
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// 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(
|
||||||
|
container_id: &str,
|
||||||
|
dest_dir: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
mode: u32,
|
||||||
|
) -> 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.
|
||||||
|
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: dest_dir.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an in-memory tar archive holding a single regular file.
|
||||||
|
///
|
||||||
|
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
|
||||||
|
/// them and Docker's archive extractor honours the header verbatim: a header
|
||||||
|
/// left at the defaults lands the file inside the container as `root:root`
|
||||||
|
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
|
||||||
|
/// listing. Callers that upload on a user's behalf should pass the container
|
||||||
|
/// user's ids from [`container_user_ids`].
|
||||||
|
pub fn build_single_file_tar(
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
mode: u32,
|
||||||
|
uid: u64,
|
||||||
|
gid: u64,
|
||||||
|
mtime: u64,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(mode);
|
||||||
|
header.set_uid(uid);
|
||||||
|
header.set_gid(gid);
|
||||||
|
header.set_mtime(mtime);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(tar_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since the Unix epoch, for a tar header mtime.
|
||||||
|
pub fn now_epoch_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numeric uid/gid of the container's `claude` user.
|
||||||
|
///
|
||||||
|
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
|
||||||
|
/// ids on Unix so bind-mounted project files stay writable, and deliberately
|
||||||
|
/// does not on Windows. So the only reliable answer comes from asking the
|
||||||
|
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||||
|
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||||
|
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||||
|
let out = exec_oneshot_limited(
|
||||||
|
container_id,
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||||
|
256,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||||
|
match (ids.next(), ids.next()) {
|
||||||
|
(Some(uid), Some(gid)) => (uid, gid),
|
||||||
|
_ => (1000, 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||||
|
/// host process.
|
||||||
|
///
|
||||||
|
/// Every `exec_oneshot*` call reads the whole stream into a `String` before any
|
||||||
|
/// caller sees a byte, and what it is reading is *container-controlled* — the
|
||||||
|
/// scheduler notifications reader `cat`s up to 50 files with no size cap, and
|
||||||
|
/// the auth bridge reads `/proc/net/tcp` every two seconds. Neither has an
|
||||||
|
/// upstream bound, so this is where the bound goes. Generous enough that no
|
||||||
|
/// legitimate reader (the largest is a package manifest of a full image) comes
|
||||||
|
/// close.
|
||||||
|
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The auth bridge's per-tick budget. It reads two procfs files whose rows are
|
||||||
|
/// ~150 bytes; a real container has tens of listeners, and the parser only ever
|
||||||
|
/// yields at most one entry per port number. 1 MiB is thousands of rows — far
|
||||||
|
/// past anything genuine, far short of a problem.
|
||||||
|
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||||
|
///
|
||||||
|
/// The byte count on its own is a fact about the transport, not about what the
|
||||||
|
/// user did — "Command output exceeded 8388608 bytes" is not a sentence anybody
|
||||||
|
/// can act on. A caller that knows what it was reading can recognise this and
|
||||||
|
/// say the useful thing instead; see `list_container_files`, where the real
|
||||||
|
/// cause is a directory with more entries than the panel can render.
|
||||||
|
pub const OUTPUT_LIMIT_MARKER: &str = "OUTPUT_LIMIT";
|
||||||
|
|
||||||
|
/// Append to `buf` while it stays inside `limit`, returning the range the chunk
|
||||||
|
/// now occupies. `None` once the limit is exceeded, at which point the caller
|
||||||
|
/// must stop reading — and nothing is appended, so a caller that ignored the
|
||||||
|
/// answer cannot parse a half-read document.
|
||||||
|
///
|
||||||
|
/// Bytes rather than `str` on purpose: Docker frames a stream wherever it
|
||||||
|
/// likes, so a chunk boundary can fall inside a UTF-8 sequence. Decoding each
|
||||||
|
/// chunk on its own turned that into two replacement characters in the middle
|
||||||
|
/// of a filename; the decode happens once, at the end, over the whole buffer.
|
||||||
|
fn push_capped(buf: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Option<(usize, usize)> {
|
||||||
|
if buf.len() + chunk.len() > limit {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = buf.len();
|
||||||
|
buf.extend_from_slice(chunk);
|
||||||
|
Some((start, buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
|
||||||
|
/// input is fully container-controlled and whose legitimate output is small.
|
||||||
|
pub async fn exec_oneshot_limited(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
|
||||||
|
.await
|
||||||
|
.map(|(output, _)| output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||||
|
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||||
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
|
/// exposed via `ps`.
|
||||||
|
///
|
||||||
|
/// NOTE: the command's exit code is NOT checked — callers that need to know
|
||||||
|
/// whether the command succeeded should use `exec_oneshot_env_status`.
|
||||||
|
pub async fn exec_oneshot_env(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
exec_oneshot_env_status(container_id, cmd, env)
|
||||||
|
.await
|
||||||
|
.map(|(output, _exit_code)| output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
|
||||||
|
/// success). The returned string contains both stdout and stderr, interleaved
|
||||||
|
/// in arrival order, which is useful for surfacing failure detail.
|
||||||
|
pub async fn exec_oneshot_env_status(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
|
exec_oneshot_as(container_id, "claude", cmd, env).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`exec_oneshot_env_status`] with the user spelled out.
|
||||||
|
///
|
||||||
|
/// Base-image migration is the only caller that needs anything but `claude`:
|
||||||
|
/// `apt-get`, `npm -g` and the payload unpack all run as **root**. Note that
|
||||||
|
/// the container does grant `claude` passwordless sudo, but going through
|
||||||
|
/// `sudo` would put the whole command in `ps` output and add a second failure
|
||||||
|
/// mode to interpret, so the exec is simply created as root.
|
||||||
|
pub async fn exec_oneshot_as(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
|
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||||
|
///
|
||||||
|
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||||
|
/// every existing caller reads, and the right one for surfacing "why did that
|
||||||
|
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||||
|
/// caller that is *parsing* output can have just that without the buffer being
|
||||||
|
/// held twice.
|
||||||
|
struct OneshotOutput {
|
||||||
|
combined: Vec<u8>,
|
||||||
|
stdout_ranges: Vec<(usize, usize)>,
|
||||||
|
exit_code: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OneshotOutput {
|
||||||
|
/// Everything the command printed, in the order it printed it.
|
||||||
|
fn text(&self) -> String {
|
||||||
|
String::from_utf8_lossy(&self.combined).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||||
|
/// mid-record is a parse error at best.
|
||||||
|
fn stdout(&self) -> String {
|
||||||
|
let mut out = Vec::with_capacity(self.combined.len());
|
||||||
|
for (start, end) in &self.stdout_ranges {
|
||||||
|
out.extend_from_slice(&self.combined[*start..*end]);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||||
|
fn stderr(&self) -> String {
|
||||||
|
let mut out = Vec::with_capacity(self.combined.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for (start, end) in &self.stdout_ranges {
|
||||||
|
out.extend_from_slice(&self.combined[cursor..*start]);
|
||||||
|
cursor = *end;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&self.combined[cursor..]);
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`exec_oneshot_as`] with the two streams kept apart, for callers that parse
|
||||||
|
/// stdout.
|
||||||
|
///
|
||||||
|
/// `find`'s own diagnostics ("Permission denied") used to arrive inside the
|
||||||
|
/// records its `-printf` was emitting. GNU `find` escapes tabs and newlines in
|
||||||
|
/// those messages, so the listing parser held — but "the parser holds" is not
|
||||||
|
/// the same as "the input is trustworthy", and the fix costs one enum match.
|
||||||
|
pub async fn exec_oneshot_streams_as(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, String, i64), String> {
|
||||||
|
let out = exec_oneshot_raw(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||||
|
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_oneshot_inner(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
|
let out = exec_oneshot_raw(container_id, user, cmd, env, limit).await?;
|
||||||
|
Ok((out.text(), out.exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_oneshot_raw(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<OneshotOutput, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
|
user: Some(user.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let result = docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut combined: Vec<u8> = Vec::new();
|
||||||
|
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||||
|
match result {
|
||||||
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
|
while let Some(msg) = output.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(data) => {
|
||||||
|
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||||
|
let bytes = data.into_bytes();
|
||||||
|
match push_capped(&mut combined, &bytes, limit) {
|
||||||
|
Some(range) => {
|
||||||
|
if from_stdout {
|
||||||
|
stdout_ranges.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop reading rather than truncate silently: every
|
||||||
|
// caller parses this output, and a half-read
|
||||||
|
// manifest or JSON array is worse than an error.
|
||||||
|
// Dropping `output` kills the exec's stream.
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"{}: Command output exceeded {} bytes and was abandoned",
|
||||||
|
OUTPUT_LIMIT_MARKER, limit
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||||
|
// final exit_code populated yet, so poll until the exec reports finished.
|
||||||
|
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||||
|
|
||||||
|
Ok(OneshotOutput {
|
||||||
|
combined,
|
||||||
|
stdout_ranges,
|
||||||
|
exit_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||||
|
///
|
||||||
|
/// `unwrap_or(0)` is how a rename that never happened reported success: callers
|
||||||
|
/// branch on `code != 0`, so an unreadable status silently became "it worked",
|
||||||
|
/// the UI closed its rename box and the file had not moved. An exec whose
|
||||||
|
/// outcome cannot be established has not been established to have succeeded —
|
||||||
|
/// fail closed and let the caller surface it.
|
||||||
|
///
|
||||||
|
/// The `test -e` probe in `rename_container_path` also fails closed under this:
|
||||||
|
/// it propagates the error instead of reading an undeterminable status as
|
||||||
|
/// "the destination does not exist".
|
||||||
|
fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||||
|
code.ok_or_else(|| {
|
||||||
|
"Could not determine whether the command finished (Docker did not report an exit status)"
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||||
|
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||||
|
/// doesn't report finished within ~5s — which shouldn't happen once its output
|
||||||
|
/// stream has drained).
|
||||||
|
///
|
||||||
|
/// The window is generous because `None` is no longer a shrug: since
|
||||||
|
/// [`require_exit_code`], it fails the whole call. Waiting a few seconds longer
|
||||||
|
/// for a busy daemon to settle costs nothing in the normal case — the loop exits
|
||||||
|
/// on the first poll that reports finished — and it is the difference between a
|
||||||
|
/// spurious "the rename failed" and a real one.
|
||||||
|
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||||
|
let docker = get_docker().ok()?;
|
||||||
|
for _ in 0..200 {
|
||||||
|
match docker.inspect_exec(exec_id).await {
|
||||||
|
Ok(info) => {
|
||||||
|
if info.running != Some(true) {
|
||||||
|
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||||
|
// that has stopped without a reported code is a status
|
||||||
|
// nobody can vouch for, and flattening it to *success* is
|
||||||
|
// the wrong default when a caller is deciding whether to
|
||||||
|
// rename a downloaded file over the user's own.
|
||||||
|
// `download_container_file` treats `None` as a failure
|
||||||
|
// precisely because it cannot tell that silence from a
|
||||||
|
// clean exit; an `unwrap_or` here made that check
|
||||||
|
// unreachable. Callers that only care about "did it fail
|
||||||
|
// loudly" use `is_some_and`, which reads `None` as before.
|
||||||
|
return info.exit_code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||||
|
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||||
|
let mut combined = Vec::new();
|
||||||
|
let mut stdout_ranges = Vec::new();
|
||||||
|
for (from_stdout, bytes) in frames {
|
||||||
|
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||||
|
if *from_stdout {
|
||||||
|
stdout_ranges.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OneshotOutput {
|
||||||
|
combined,
|
||||||
|
stdout_ranges,
|
||||||
|
exit_code: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_under_the_limit_is_buffered_whole() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert_eq!(push_capped(&mut buf, b"hello ", 16), Some((0, 6)));
|
||||||
|
assert_eq!(push_capped(&mut buf, b"world", 16), Some((6, 11)));
|
||||||
|
assert_eq!(buf, b"hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||||
|
// The abandoned chunk must not land in the buffer either: a caller that
|
||||||
|
// ignored the error would otherwise parse a half-read document.
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_some());
|
||||||
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_none());
|
||||||
|
assert_eq!(buf, b"0123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_oversized_chunk_is_refused() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert!(push_capped(&mut buf, b"0123456789", 4).is_none());
|
||||||
|
assert!(buf.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_character_split_across_two_frames_survives_the_decode() {
|
||||||
|
// Docker frames a stream wherever it likes, and a filename is where
|
||||||
|
// that shows: decoding each chunk on its own turned the two halves of
|
||||||
|
// `ü` into two replacement characters in the middle of a name.
|
||||||
|
let out = collect(&[(true, &[0xc3]), (true, &[0xbc, b'.', b't', b'x', b't'])]);
|
||||||
|
assert_eq!(out.stdout(), "ü.txt");
|
||||||
|
assert_eq!(out.text(), "ü.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_diagnostic_never_lands_in_the_stream_a_caller_parses() {
|
||||||
|
// `find`'s "Permission denied" used to arrive inside the records its
|
||||||
|
// `-printf` was emitting. Arrival order is still available for the
|
||||||
|
// error message; the parser gets stdout alone.
|
||||||
|
let out = collect(&[
|
||||||
|
(true, b"first"),
|
||||||
|
(false, b"find: /x: Permission denied\n"),
|
||||||
|
(true, b"second"),
|
||||||
|
]);
|
||||||
|
assert_eq!(out.stdout(), "firstsecond");
|
||||||
|
assert_eq!(out.stderr(), "find: /x: Permission denied\n");
|
||||||
|
assert_eq!(out.text(), "firstfind: /x: Permission denied\nsecond");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_output_limit_refusal_is_marked_so_a_caller_can_reword_it() {
|
||||||
|
// "Command output exceeded 8388608 bytes" is a fact about a buffer.
|
||||||
|
// The marker is what lets `list_container_files` say "too many entries"
|
||||||
|
// instead, which is the thing that actually happened.
|
||||||
|
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||||
|
let refusal = format!(
|
||||||
|
"{}: Command output exceeded {} bytes and was abandoned",
|
||||||
|
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||||
|
);
|
||||||
|
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undeterminable_exit_status_is_an_error_not_a_zero() {
|
||||||
|
// The bug this guards: `unwrap_or(0)` made every caller that branches on
|
||||||
|
// `code != 0` — rename, mkdir — report success for an exec whose outcome
|
||||||
|
// nobody could read.
|
||||||
|
assert_eq!(require_exit_code(Some(0)).unwrap(), 0);
|
||||||
|
assert_eq!(require_exit_code(Some(1)).unwrap(), 1);
|
||||||
|
assert!(require_exit_code(None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||||
|
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
||||||
|
// gets a tighter ceiling than one-shot readers that run on demand.
|
||||||
|
assert!(PROC_NET_OUTPUT_LIMIT < MAX_ONESHOT_OUTPUT);
|
||||||
|
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||||
|
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported path, which is what the terminal echoes back to Claude and
|
||||||
|
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||||
|
/// same file to the kernel and different strings to a person reading either
|
||||||
|
/// of those.
|
||||||
|
#[test]
|
||||||
|
fn container_join_produces_one_separator() {
|
||||||
|
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||||
|
// The terminal's drop passes a nested name; it must not gain a second
|
||||||
|
// slash at the seam.
|
||||||
|
assert_eq!(
|
||||||
|
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||||
|
"/tmp/triple-c-drops/a.txt"
|
||||||
|
);
|
||||||
|
// A directory the user navigated to can carry a trailing slash, and the
|
||||||
|
// container root is the case where trimming it must not eat the only
|
||||||
|
// separator there is.
|
||||||
|
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||||
|
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||||
|
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,949 @@
|
|||||||
|
//! Lifecycle for the **model gateway** container — a pinned LiteLLM proxy that
|
||||||
|
//! Triple-C runs as a sibling of the project containers.
|
||||||
|
//!
|
||||||
|
//! Shape mirrors `docker::stt`: an image that is either pulled from a registry
|
||||||
|
//! or built locally from an embedded Dockerfile, a fixed container name, a
|
||||||
|
//! named volume, and `get_* / ensure_*_running / stop_* / pull_* / build_*`.
|
||||||
|
//!
|
||||||
|
//! Two things differ from STT, both deliberate:
|
||||||
|
//!
|
||||||
|
//! * **The published host address is *detected*, not fixed.** STT is consumed
|
||||||
|
//! by the Tauri host process, so loopback is always enough. The gateway is
|
||||||
|
//! consumed by *project containers*, and how a container reaches the host
|
||||||
|
//! depends on the engine — so the bind address does too. See
|
||||||
|
//! [`GatewayBinding`]. It is never `0.0.0.0`: the config behind this port
|
||||||
|
//! holds a billed provider key, and Docker's published-port rules land in the
|
||||||
|
//! `DOCKER` iptables chain *ahead* of a host firewall, so a wildcard bind is
|
||||||
|
//! genuinely LAN-reachable even with `ufw` enabled.
|
||||||
|
//! * **The rendered config is uploaded into the container over the Docker
|
||||||
|
//! API** rather than passed as env. It holds the provider API key, and both
|
||||||
|
//! env vars and labels are readable by anything on the host via
|
||||||
|
//! `docker inspect`.
|
||||||
|
|
||||||
|
use bollard::container::{
|
||||||
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
|
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::image::BuildImageOptions;
|
||||||
|
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
|
use bollard::network::InspectNetworkOptions;
|
||||||
|
use bollard::Docker;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::{Mutex, OnceCell};
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
use crate::models::gateway_settings::{GatewaySettings, GatewayStatus};
|
||||||
|
use crate::storage::secure;
|
||||||
|
|
||||||
|
const GATEWAY_CONTAINER_NAME: &str = "triple-c-gateway";
|
||||||
|
const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
|
||||||
|
|
||||||
|
/// Upstream LiteLLM, pinned to an exact release.
|
||||||
|
///
|
||||||
|
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
|
||||||
|
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
|
||||||
|
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
|
||||||
|
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
|
||||||
|
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
|
||||||
|
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
|
||||||
|
|
||||||
|
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
|
||||||
|
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
|
||||||
|
|
||||||
|
/// Where the generated config lands inside the container. Backed by
|
||||||
|
/// [`GATEWAY_CONFIG_VOLUME`] so the file with the provider key lives in a
|
||||||
|
/// Docker-managed volume rather than an image layer.
|
||||||
|
const GATEWAY_CONFIG_DIR: &str = "/etc/litellm";
|
||||||
|
const GATEWAY_CONFIG_PATH: &str = "/etc/litellm/config.yaml";
|
||||||
|
|
||||||
|
/// Container-side port. Only the *host* port is user-configurable.
|
||||||
|
const GATEWAY_INTERNAL_PORT: u16 = 4000;
|
||||||
|
|
||||||
|
const CONFIG_FINGERPRINT_LABEL: &str = "triple-c.gateway.config-fingerprint";
|
||||||
|
|
||||||
|
/// The default bridge gateway address on a stock native-Linux engine. Only a
|
||||||
|
/// fallback: the real value is read from the `bridge` network's IPAM config.
|
||||||
|
const DEFAULT_BRIDGE_GATEWAY: &str = "172.17.0.1";
|
||||||
|
|
||||||
|
/// Where the gateway's published port is bound on the host, and the address a
|
||||||
|
/// *project container* uses to reach it.
|
||||||
|
///
|
||||||
|
/// Project containers run on Docker's default bridge with no user-defined
|
||||||
|
/// network and no `--add-host`, so the only address they share with the gateway
|
||||||
|
/// is the host itself — but *which* host address works is engine-specific, and
|
||||||
|
/// the whole point of this type is that the two answers are derived together so
|
||||||
|
/// they cannot drift apart:
|
||||||
|
///
|
||||||
|
/// * **Docker Desktop** (macOS / Windows / WSL2) resolves `host.docker.internal`
|
||||||
|
/// from inside containers automatically, and its port forwarder reaches the
|
||||||
|
/// host's *loopback*. So: bind `127.0.0.1`, hand out `host.docker.internal`.
|
||||||
|
/// * **Native Linux Docker** injects no `host.docker.internal`, and the address
|
||||||
|
/// containers share with the host is the default bridge gateway (normally
|
||||||
|
/// `172.17.0.1`). So: bind that address, and hand out the same literal.
|
||||||
|
///
|
||||||
|
/// Neither case binds `0.0.0.0`. The bridge-gateway bind is reachable from
|
||||||
|
/// every container on the default bridge — which is the requirement — without
|
||||||
|
/// publishing a key-bearing proxy to the LAN.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GatewayBinding {
|
||||||
|
/// Host address the published port is bound to (`HostIp`).
|
||||||
|
pub host_ip: String,
|
||||||
|
/// Host address a project container should dial.
|
||||||
|
pub container_host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayBinding {
|
||||||
|
fn desktop() -> Self {
|
||||||
|
Self {
|
||||||
|
host_ip: "127.0.0.1".to_string(),
|
||||||
|
container_host: "host.docker.internal".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bridge(gateway_ip: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
host_ip: gateway_ip.to_string(),
|
||||||
|
container_host: gateway_ip.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value a project should use as its base URL (`ANTHROPIC_BASE_URL`).
|
||||||
|
pub fn base_url(&self, port: u16) -> String {
|
||||||
|
format!("http://{}:{}", self.container_host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address the *host* process (health checks) should dial.
|
||||||
|
fn host_url(&self, port: u16) -> String {
|
||||||
|
format!("http://{}:{}", self.host_ip, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide the binding from what the daemon reports. Pure, so the engine-shape
|
||||||
|
/// matrix is testable without a daemon.
|
||||||
|
fn binding_for(operating_system: &str, bridge_gateway: Option<&str>) -> GatewayBinding {
|
||||||
|
// Docker Desktop reports exactly "Docker Desktop" here on every platform it
|
||||||
|
// ships for; matched loosely so a future suffix doesn't silently flip us
|
||||||
|
// onto the bridge path.
|
||||||
|
if operating_system.to_ascii_lowercase().contains("docker desktop") {
|
||||||
|
return GatewayBinding::desktop();
|
||||||
|
}
|
||||||
|
GatewayBinding::bridge(
|
||||||
|
bridge_gateway
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|g| !g.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_BRIDGE_GATEWAY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detection is one `info` + one `inspect_network` per process; the answer
|
||||||
|
/// cannot change without the engine being replaced under us.
|
||||||
|
static GATEWAY_BINDING: OnceCell<GatewayBinding> = OnceCell::const_new();
|
||||||
|
|
||||||
|
/// The gateway's host binding, detected once and cached.
|
||||||
|
///
|
||||||
|
/// When Docker is unreachable the *loopback* answer is returned without being
|
||||||
|
/// cached: it is the conservative one (nothing is published anywhere yet, and
|
||||||
|
/// the only caller in that state is status reporting), and the next call
|
||||||
|
/// re-detects once the daemon is up.
|
||||||
|
pub async fn gateway_binding() -> GatewayBinding {
|
||||||
|
if let Some(binding) = GATEWAY_BINDING.get() {
|
||||||
|
return binding.clone();
|
||||||
|
}
|
||||||
|
match detect_binding().await {
|
||||||
|
Ok(binding) => {
|
||||||
|
let _ = GATEWAY_BINDING.set(binding.clone());
|
||||||
|
binding
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Gateway bind detection deferred ({}), assuming loopback", e);
|
||||||
|
GatewayBinding::desktop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect_binding() -> Result<GatewayBinding, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let info = docker
|
||||||
|
.info()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to query the Docker daemon: {}", e))?;
|
||||||
|
let operating_system = info.operating_system.unwrap_or_default();
|
||||||
|
let gateway_ip = bridge_gateway_ip(&docker).await;
|
||||||
|
let binding = binding_for(&operating_system, gateway_ip.as_deref());
|
||||||
|
log::info!(
|
||||||
|
"Model gateway will publish on {} (engine OS: {})",
|
||||||
|
binding.host_ip,
|
||||||
|
if operating_system.is_empty() {
|
||||||
|
"unknown"
|
||||||
|
} else {
|
||||||
|
&operating_system
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default bridge's gateway address, straight from its IPAM config, so a
|
||||||
|
/// host whose bridge subnet was customised still gets a reachable bind.
|
||||||
|
async fn bridge_gateway_ip(docker: &Docker) -> Option<String> {
|
||||||
|
let network = docker
|
||||||
|
.inspect_network("bridge", None::<InspectNetworkOptions<String>>)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
network
|
||||||
|
.ipam?
|
||||||
|
.config?
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|c| c.gateway.filter(|g| !g.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||||
|
let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let (container_exists, running) = match find_gateway_container().await? {
|
||||||
|
Some((_, state, _)) => (true, state == "running"),
|
||||||
|
None => (false, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GatewayStatus {
|
||||||
|
container_exists,
|
||||||
|
running,
|
||||||
|
port: settings.port,
|
||||||
|
image_exists,
|
||||||
|
model_count: settings.valid_models().len(),
|
||||||
|
has_api_key: secure::has_gateway_api_key(),
|
||||||
|
base_url: gateway_binding().await.base_url(settings.port),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a gateway container exists, and whether it is running. Used by the
|
||||||
|
/// settings reconcile, which must not start anything the user never started.
|
||||||
|
pub async fn gateway_container_presence() -> Result<(bool, bool), String> {
|
||||||
|
Ok(match find_gateway_container().await? {
|
||||||
|
Some((_, state, _)) => (true, state == "running"),
|
||||||
|
None => (false, false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a container summary's names contain *exactly* our container.
|
||||||
|
///
|
||||||
|
/// Docker's `name` filter is an unanchored regex, so listing with it also
|
||||||
|
/// returns `triple-c-gateway-backup`, `my-triple-c-gateway`, and anything else
|
||||||
|
/// containing the string. Taking `.first()` of that would let this module
|
||||||
|
/// adopt — and then force-remove — a container it does not own.
|
||||||
|
/// `container::find_existing_container` matches exactly for the same reason.
|
||||||
|
fn is_gateway_container(names: Option<&Vec<String>>) -> bool {
|
||||||
|
let expected = format!("/{}", GATEWAY_CONTAINER_NAME);
|
||||||
|
names.is_some_and(|names| names.iter().any(|n| n == &expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(id, state, config fingerprint label)` for the gateway container, if any.
|
||||||
|
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"name".to_string(),
|
||||||
|
vec![format!("/{}", GATEWAY_CONTAINER_NAME)],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
|
// The filter is a prefilter only — the exact-name check is what decides.
|
||||||
|
for container in &containers {
|
||||||
|
if !is_gateway_container(container.names.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = container.id.clone().unwrap_or_default();
|
||||||
|
let state = container.state.clone().unwrap_or_default();
|
||||||
|
let fingerprint = container
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.get(CONFIG_FINGERPRINT_LABEL))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
return Ok(Some((id, state, fingerprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Config generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Render a YAML double-quoted scalar.
|
||||||
|
///
|
||||||
|
/// Everything that reaches the config comes from user input (model names, base
|
||||||
|
/// URLs, keys), so nothing may be interpolated raw — a stray `"` or newline
|
||||||
|
/// would otherwise rewrite the document.
|
||||||
|
fn yaml_str(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in value.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts of the config that are safe to hash into a Docker label — i.e.
|
||||||
|
/// everything except the two secrets, whose changes are tracked by the
|
||||||
|
/// keychain rotation id instead.
|
||||||
|
fn config_shape(settings: &GatewaySettings, binding: &GatewayBinding) -> String {
|
||||||
|
let models: Vec<String> = settings
|
||||||
|
.valid_models()
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("{}={}", m.name.trim(), m.model_id.trim()))
|
||||||
|
.collect();
|
||||||
|
// `bind` is part of the shape so that moving between engines (or a bridge
|
||||||
|
// subnet change) recreates the container instead of leaving it published on
|
||||||
|
// an address the new environment doesn't use.
|
||||||
|
format!(
|
||||||
|
"provider={};api_base={};port={};bind={};models={}",
|
||||||
|
settings.provider.trim(),
|
||||||
|
settings.api_base.as_deref().unwrap_or("").trim(),
|
||||||
|
settings.port,
|
||||||
|
binding.host_ip,
|
||||||
|
models.join(",")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the LiteLLM config for the current settings.
|
||||||
|
///
|
||||||
|
/// `api_key` and `master_key` come from the keychain. The returned string
|
||||||
|
/// contains both — it goes straight into the Docker upload and must never be
|
||||||
|
/// logged or surfaced.
|
||||||
|
fn render_config(settings: &GatewaySettings, api_key: &str, master_key: &str) -> String {
|
||||||
|
let provider = settings.provider.trim();
|
||||||
|
let api_base = settings
|
||||||
|
.api_base
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let mut out = String::from(
|
||||||
|
"# Generated by Triple-C — do not edit by hand; it is overwritten on every\n\
|
||||||
|
# gateway (re)start from Settings → Model Gateway.\n\
|
||||||
|
model_list:\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
for model in settings.valid_models() {
|
||||||
|
out.push_str(&format!(" - model_name: {}\n", yaml_str(model.name.trim())));
|
||||||
|
out.push_str(" litellm_params:\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
" model: {}\n",
|
||||||
|
yaml_str(&format!("{}/{}", provider, model.model_id.trim()))
|
||||||
|
));
|
||||||
|
out.push_str(&format!(" api_key: {}\n", yaml_str(api_key)));
|
||||||
|
if let Some(base) = api_base {
|
||||||
|
out.push_str(&format!(" api_base: {}\n", yaml_str(base)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("general_settings:\n");
|
||||||
|
out.push_str(&format!(" master_key: {}\n", yaml_str(master_key)));
|
||||||
|
out.push_str("litellm_settings:\n");
|
||||||
|
// Claude Code's Anthropic-format requests carry fields some providers
|
||||||
|
// reject outright; dropping the unsupported ones is what lets the
|
||||||
|
// translation survive across providers.
|
||||||
|
out.push_str(" drop_params: true\n");
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload the rendered config into the container's config volume.
|
||||||
|
///
|
||||||
|
/// Runs against a *created but not yet started* container, which is when the
|
||||||
|
/// volume already exists but LiteLLM has not read anything from it.
|
||||||
|
async fn upload_config(container_id: &str, config: &str) -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(config.len() as u64);
|
||||||
|
// World-readable: the upstream image may run LiteLLM as a non-root
|
||||||
|
// user, and a root-owned 0600 file would simply be unreadable. The
|
||||||
|
// secret is only exposed to the gateway container itself, which is
|
||||||
|
// the one process that needs it.
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
archive
|
||||||
|
.append_data(&mut header, "config.yaml", config.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
|
||||||
|
archive
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
|
||||||
|
}
|
||||||
|
let _ = buf.flush();
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: GATEWAY_CONFIG_DIR,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload the gateway config: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Lifecycle
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn create_gateway_container(
|
||||||
|
settings: &GatewaySettings,
|
||||||
|
binding: &GatewayBinding,
|
||||||
|
fingerprint: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Local build first, then the pinned upstream image — same precedence as
|
||||||
|
// the STT container.
|
||||||
|
let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
GATEWAY_LOCAL_IMAGE.to_string()
|
||||||
|
} else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
GATEWAY_REGISTRY_IMAGE.to_string()
|
||||||
|
} else {
|
||||||
|
return Err(
|
||||||
|
"Gateway image not found. Please pull or build the image first.".to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut port_bindings = HashMap::new();
|
||||||
|
port_bindings.insert(
|
||||||
|
format!("{}/tcp", GATEWAY_INTERNAL_PORT),
|
||||||
|
Some(vec![PortBinding {
|
||||||
|
// Never `0.0.0.0`: the narrowest host address project containers
|
||||||
|
// can still reach. See `GatewayBinding`.
|
||||||
|
host_ip: Some(binding.host_ip.clone()),
|
||||||
|
host_port: Some(settings.port.to_string()),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||||
|
exposed_ports.insert(format!("{}/tcp", GATEWAY_INTERNAL_PORT), HashMap::new());
|
||||||
|
|
||||||
|
let host_config = HostConfig {
|
||||||
|
port_bindings: Some(port_bindings),
|
||||||
|
mounts: Some(vec![Mount {
|
||||||
|
target: Some(GATEWAY_CONFIG_DIR.to_string()),
|
||||||
|
source: Some(GATEWAY_CONFIG_VOLUME.to_string()),
|
||||||
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
init: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-secret only. Labels are readable by anything on the host.
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
labels.insert(CONFIG_FINGERPRINT_LABEL.to_string(), fingerprint.to_string());
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.gateway.port".to_string(),
|
||||||
|
settings.port.to_string(),
|
||||||
|
);
|
||||||
|
labels.insert("triple-c.gateway.bind".to_string(), binding.host_ip.clone());
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.gateway.provider".to_string(),
|
||||||
|
settings.provider.trim().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
image: Some(image),
|
||||||
|
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
|
||||||
|
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
|
||||||
|
// our locally built one behave identically.
|
||||||
|
cmd: Some(vec![
|
||||||
|
"--config".to_string(),
|
||||||
|
GATEWAY_CONFIG_PATH.to_string(),
|
||||||
|
"--host".to_string(),
|
||||||
|
"0.0.0.0".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
GATEWAY_INTERNAL_PORT.to_string(),
|
||||||
|
]),
|
||||||
|
exposed_ports: Some(exposed_ports),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
labels: Some(labels),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CreateContainerOptions {
|
||||||
|
name: GATEWAY_CONTAINER_NAME,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = docker
|
||||||
|
.create_container(Some(options), config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create gateway container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialises every mutation of the single fixed-name gateway container.
|
||||||
|
///
|
||||||
|
/// `ensure_gateway_running` is check-then-act over one container name, so two
|
||||||
|
/// concurrent callers — the setup auto-start and the user's Start button is the
|
||||||
|
/// realistic pair — would both see `None` and both try to create it, and the
|
||||||
|
/// loser would surface a raw Docker 409. Migration guards the same shape with
|
||||||
|
/// `ActiveGuard`; here the right behaviour is to *serialise* rather than
|
||||||
|
/// refuse, because the second caller then observes the first's container, finds
|
||||||
|
/// a matching fingerprint, and returns its status — which is exactly what it
|
||||||
|
/// asked for.
|
||||||
|
fn gateway_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||||
|
let _guard = gateway_lock().lock().await;
|
||||||
|
ensure_gateway_running_locked(settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_gateway_running_locked(
|
||||||
|
settings: &GatewaySettings,
|
||||||
|
) -> Result<GatewayStatus, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
if settings.valid_models().is_empty() {
|
||||||
|
return Err(
|
||||||
|
"The gateway has no models configured. Add at least one model in Settings."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = secure::get_gateway_api_key()?
|
||||||
|
.filter(|k| !k.trim().is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"No provider API key stored for the gateway. Add one in Settings.".to_string()
|
||||||
|
})?;
|
||||||
|
let master_key = secure::get_or_create_gateway_master_key()?;
|
||||||
|
|
||||||
|
let binding = gateway_binding().await;
|
||||||
|
|
||||||
|
// Rotation id, not a hash of either secret — see `storage::secure`.
|
||||||
|
let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default();
|
||||||
|
let fingerprint = sha256_hex(&format!(
|
||||||
|
"{}|{}",
|
||||||
|
config_shape(settings, &binding),
|
||||||
|
secret_version
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
|
||||||
|
if existing_fingerprint == fingerprint {
|
||||||
|
if state == "running" {
|
||||||
|
return get_gateway_status(settings).await;
|
||||||
|
}
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
|
||||||
|
return get_gateway_status(settings).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config or a secret changed — recreate so the new config is uploaded.
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(&id, None::<StopContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||||
|
}
|
||||||
|
docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to remove gateway container: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = create_gateway_container(settings, &binding, &fingerprint).await?;
|
||||||
|
|
||||||
|
// Upload before the first start: LiteLLM reads the config once at boot.
|
||||||
|
let rendered = render_config(settings, &api_key, &master_key);
|
||||||
|
if let Err(e) = upload_config(&id, &rendered).await {
|
||||||
|
// Don't leave a half-configured container behind for the next run to
|
||||||
|
// mistake for a good one.
|
||||||
|
let _ = docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Model gateway started on {}:{} ({} model(s))",
|
||||||
|
binding.host_ip,
|
||||||
|
settings.port,
|
||||||
|
settings.valid_models().len()
|
||||||
|
);
|
||||||
|
|
||||||
|
get_gateway_status(settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grace period given to LiteLLM on stop. The Docker default is 10s, which app
|
||||||
|
/// exit cannot afford to spend on a proxy that holds no state worth flushing.
|
||||||
|
const GATEWAY_STOP_GRACE_SECS: i64 = 3;
|
||||||
|
|
||||||
|
pub async fn stop_gateway_container() -> Result<(), String> {
|
||||||
|
// Same lock as `ensure_gateway_running`, so a stop can't interleave with a
|
||||||
|
// create/start and leave a container running behind a "stopped" return.
|
||||||
|
let _guard = gateway_lock().lock().await;
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
if let Some((id, state, _)) = find_gateway_container().await? {
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(
|
||||||
|
&id,
|
||||||
|
Some(StopContainerOptions {
|
||||||
|
t: GATEWAY_STOP_GRACE_SECS,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running gateway whether it is up. LiteLLM takes several seconds to
|
||||||
|
/// boot, so "container running" and "gateway answering" are not the same thing.
|
||||||
|
pub async fn check_gateway_health(port: u16) -> Result<bool, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
// Dial whatever the container is actually published on — with a
|
||||||
|
// bridge-gateway bind, the host's loopback answers nothing.
|
||||||
|
let base = gateway_binding().await.host_url(port);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get(format!("{}/health/liveliness", base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => Ok(response.status().is_success()),
|
||||||
|
Err(e) if e.is_connect() || e.is_timeout() => Ok(false),
|
||||||
|
Err(e) => Err(format!("Gateway health check failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
super::image::pull_image(GATEWAY_REGISTRY_IMAGE, on_progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let tar_bytes = create_gateway_build_context()
|
||||||
|
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
|
||||||
|
|
||||||
|
let options = BuildImageOptions {
|
||||||
|
t: GATEWAY_LOCAL_IMAGE,
|
||||||
|
rm: true,
|
||||||
|
forcerm: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||||
|
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(output) => {
|
||||||
|
if let Some(stream) = output.stream {
|
||||||
|
on_progress(stream);
|
||||||
|
}
|
||||||
|
if let Some(error) = output.error {
|
||||||
|
return Err(format!("Build error: {}", error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_gateway_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
|
let mut dockerfile_header = tar::Header::new_gnu();
|
||||||
|
dockerfile_header.set_size(GATEWAY_DOCKERFILE.len() as u64);
|
||||||
|
dockerfile_header.set_mode(0o644);
|
||||||
|
dockerfile_header.set_cksum();
|
||||||
|
archive.append_data(
|
||||||
|
&mut dockerfile_header,
|
||||||
|
"Dockerfile",
|
||||||
|
GATEWAY_DOCKERFILE.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut config_header = tar::Header::new_gnu();
|
||||||
|
config_header.set_size(GATEWAY_DEFAULT_CONFIG.len() as u64);
|
||||||
|
config_header.set_mode(0o644);
|
||||||
|
config_header.set_cksum();
|
||||||
|
archive.append_data(
|
||||||
|
&mut config_header,
|
||||||
|
"config.yaml",
|
||||||
|
GATEWAY_DEFAULT_CONFIG.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
archive.finish()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = buf.flush();
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::gateway_settings::GatewayModel;
|
||||||
|
|
||||||
|
fn settings() -> GatewaySettings {
|
||||||
|
GatewaySettings {
|
||||||
|
enabled: true,
|
||||||
|
port: 4000,
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
api_base: None,
|
||||||
|
models: vec![
|
||||||
|
GatewayModel {
|
||||||
|
name: "gpt-5.1".to_string(),
|
||||||
|
model_id: "gpt-5.1".to_string(),
|
||||||
|
},
|
||||||
|
// Half-filled rows must not reach the YAML.
|
||||||
|
GatewayModel {
|
||||||
|
name: " ".to_string(),
|
||||||
|
model_id: "gpt-4o".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_models_skips_incomplete_rows() {
|
||||||
|
assert_eq!(settings().valid_models().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_config_composes_provider_and_model_id() {
|
||||||
|
let yaml = render_config(&settings(), "sk-provider", "sk-master");
|
||||||
|
assert!(yaml.contains("model_name: \"gpt-5.1\""));
|
||||||
|
assert!(yaml.contains("model: \"openai/gpt-5.1\""));
|
||||||
|
assert!(yaml.contains("api_key: \"sk-provider\""));
|
||||||
|
assert!(yaml.contains("master_key: \"sk-master\""));
|
||||||
|
assert!(yaml.contains("drop_params: true"));
|
||||||
|
// The skipped row must be absent.
|
||||||
|
assert!(!yaml.contains("gpt-4o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_config_emits_api_base_only_when_set() {
|
||||||
|
let mut s = settings();
|
||||||
|
assert!(!render_config(&s, "k", "m").contains("api_base"));
|
||||||
|
s.api_base = Some("https://example.test/v1".to_string());
|
||||||
|
assert!(render_config(&s, "k", "m").contains("api_base: \"https://example.test/v1\""));
|
||||||
|
// Blank is treated as unset rather than emitted as an empty URL.
|
||||||
|
s.api_base = Some(" ".to_string());
|
||||||
|
assert!(!render_config(&s, "k", "m").contains("api_base"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yaml_str_escapes_injection_attempts() {
|
||||||
|
let hostile = "a\"\nmaster_key: \"pwned";
|
||||||
|
let quoted = yaml_str(hostile);
|
||||||
|
assert!(quoted.starts_with('"') && quoted.ends_with('"'));
|
||||||
|
// No raw newline can escape the scalar and start a new YAML key.
|
||||||
|
assert!(!quoted[1..quoted.len() - 1].contains('\n'));
|
||||||
|
assert!(quoted.contains("\\\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_shape_excludes_secrets_and_tracks_changes() {
|
||||||
|
let binding = GatewayBinding::desktop();
|
||||||
|
let a = config_shape(&settings(), &binding);
|
||||||
|
let mut s = settings();
|
||||||
|
s.models[0].model_id = "gpt-4.1".to_string();
|
||||||
|
assert_ne!(a, config_shape(&s, &binding));
|
||||||
|
assert!(!a.contains("sk-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_shape_tracks_the_bind_address() {
|
||||||
|
// Moving between engines must recreate the container rather than leave
|
||||||
|
// it published on an address the new environment doesn't use.
|
||||||
|
let s = settings();
|
||||||
|
assert_ne!(
|
||||||
|
config_shape(&s, &GatewayBinding::desktop()),
|
||||||
|
config_shape(&s, &GatewayBinding::bridge("172.17.0.1"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn docker_desktop_binds_loopback_and_hands_out_host_docker_internal() {
|
||||||
|
let binding = binding_for("Docker Desktop", None);
|
||||||
|
assert_eq!(binding.host_ip, "127.0.0.1");
|
||||||
|
assert_eq!(binding.base_url(4000), "http://host.docker.internal:4000");
|
||||||
|
// Detection must not depend on the bridge answer on this engine.
|
||||||
|
assert_eq!(binding, binding_for("Docker Desktop", Some("172.17.0.1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_linux_binds_the_bridge_gateway_it_reports() {
|
||||||
|
// A project container can't reach the host's loopback here, but it can
|
||||||
|
// reach the bridge gateway — and so can nothing on the LAN.
|
||||||
|
let binding = binding_for("Ubuntu 24.04.1 LTS", Some("172.19.0.1"));
|
||||||
|
assert_eq!(binding.host_ip, "172.19.0.1");
|
||||||
|
assert_eq!(binding.base_url(4000), "http://172.19.0.1:4000");
|
||||||
|
assert_eq!(binding.host_url(4000), "http://172.19.0.1:4000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_bridge_answer_falls_back_to_the_documented_default() {
|
||||||
|
for reported in [None, Some(""), Some(" ")] {
|
||||||
|
assert_eq!(
|
||||||
|
binding_for("Ubuntu 24.04.1 LTS", reported).host_ip,
|
||||||
|
"172.17.0.1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_engine_shape_ever_binds_a_wildcard_address() {
|
||||||
|
// The regression this guards: the published port fronts a container
|
||||||
|
// config holding a billed provider key, and Docker's rules sit ahead of
|
||||||
|
// the host firewall.
|
||||||
|
for os in ["Docker Desktop", "Ubuntu 24.04.1 LTS", "", "Rancher Desktop"] {
|
||||||
|
for gw in [None, Some("172.17.0.1"), Some("10.0.0.1")] {
|
||||||
|
let host_ip = binding_for(os, gw).host_ip;
|
||||||
|
assert_ne!(host_ip, "0.0.0.0", "os={:?} gw={:?}", os, gw);
|
||||||
|
assert_ne!(host_ip, "::", "os={:?} gw={:?}", os, gw);
|
||||||
|
assert!(!host_ip.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_exact_container_name_is_adopted() {
|
||||||
|
// Docker's `name` filter is an unanchored regex: all of these come back
|
||||||
|
// from a filtered list. Adopting one would force-remove a user's
|
||||||
|
// container.
|
||||||
|
assert!(is_gateway_container(Some(&vec![
|
||||||
|
"/triple-c-gateway".to_string()
|
||||||
|
])));
|
||||||
|
assert!(is_gateway_container(Some(&vec![
|
||||||
|
"/something-else".to_string(),
|
||||||
|
"/triple-c-gateway".to_string(),
|
||||||
|
])));
|
||||||
|
for impostor in [
|
||||||
|
"/triple-c-gateway-backup",
|
||||||
|
"/my-triple-c-gateway",
|
||||||
|
"/triple-c-gateway2",
|
||||||
|
"triple-c-gateway",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_gateway_container(Some(&vec![impostor.to_string()])),
|
||||||
|
"{} must not be adopted",
|
||||||
|
impostor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!is_gateway_container(None));
|
||||||
|
assert!(!is_gateway_container(Some(&vec![])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_gateway_lock_serialises_concurrent_callers() {
|
||||||
|
// The auto-start racing the Start button: both would otherwise see no
|
||||||
|
// container and both create one, and the loser gets a Docker 409.
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let inside = Arc::new(AtomicUsize::new(0));
|
||||||
|
let overlaps = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let inside = inside.clone();
|
||||||
|
let overlaps = overlaps.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let _guard = gateway_lock().lock().await;
|
||||||
|
if inside.fetch_add(1, Ordering::SeqCst) != 0 {
|
||||||
|
overlaps.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||||
|
inside.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for t in tasks {
|
||||||
|
t.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(overlaps.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(inside.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
|
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
|
||||||
use bollard::models::ImageSummary;
|
use bollard::models::ImageSummary;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use include_dir::{include_dir, Dir};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ use crate::models::container_config;
|
|||||||
|
|
||||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||||
|
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||||
|
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||||
|
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
|
||||||
|
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
|
||||||
|
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
|
||||||
|
|
||||||
|
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
|
||||||
|
|
||||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -29,6 +37,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
|||||||
Ok(!images.is_empty())
|
Ok(!images.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
|
||||||
|
/// or None if the image doesn't exist locally or has no repo digests.
|
||||||
|
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"reference".to_string(),
|
||||||
|
vec![image_name.to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let images: Vec<ImageSummary> = docker
|
||||||
|
.list_images(Some(ListImagesOptions {
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(img) = images.first() {
|
||||||
|
// RepoDigests contains entries like "registry/repo@sha256:abc..."
|
||||||
|
if let Some(digest_str) = img.repo_digests.first() {
|
||||||
|
// Extract the sha256:... part after '@'
|
||||||
|
if let Some(pos) = digest_str.find('@') {
|
||||||
|
return Ok(Some(digest_str[pos + 1..].to_string()));
|
||||||
|
}
|
||||||
|
return Ok(Some(digest_str.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: Fn(String) + Send + 'static,
|
F: Fn(String) + Send + 'static,
|
||||||
@@ -116,24 +156,48 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_file_to_archive(
|
||||||
|
archive: &mut tar::Builder<&mut Vec<u8>>,
|
||||||
|
path: &str,
|
||||||
|
content: &[u8],
|
||||||
|
mode: u32,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_mode(mode);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_embedded_dir(
|
||||||
|
archive: &mut tar::Builder<&mut Vec<u8>>,
|
||||||
|
dir: &Dir,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
for file in dir.files() {
|
||||||
|
let path = format!("{}/{}", prefix, file.path().display());
|
||||||
|
append_file_to_archive(archive, &path, file.contents(), 0o644)?;
|
||||||
|
}
|
||||||
|
for subdir in dir.dirs() {
|
||||||
|
append_embedded_dir(archive, subdir, prefix)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
{
|
{
|
||||||
let mut archive = tar::Builder::new(&mut buf);
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
append_file_to_archive(&mut archive, "Dockerfile", DOCKERFILE.as_bytes(), 0o644)?;
|
||||||
let mut header = tar::Header::new_gnu();
|
append_file_to_archive(&mut archive, "entrypoint.sh", ENTRYPOINT.as_bytes(), 0o755)?;
|
||||||
header.set_size(dockerfile_bytes.len() as u64);
|
append_file_to_archive(&mut archive, "triple-c-scheduler", SCHEDULER.as_bytes(), 0o755)?;
|
||||||
header.set_mode(0o644);
|
append_file_to_archive(&mut archive, "triple-c-task-runner", TASK_RUNNER.as_bytes(), 0o755)?;
|
||||||
header.set_cksum();
|
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
|
||||||
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
|
||||||
|
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
|
||||||
|
|
||||||
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
|
||||||
let mut header = tar::Header::new_gnu();
|
|
||||||
header.set_size(entrypoint_bytes.len() as u64);
|
|
||||||
header.set_mode(0o755);
|
|
||||||
header.set_cksum();
|
|
||||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
|
||||||
|
|
||||||
archive.finish()?;
|
archive.finish()?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! One-release migration shim for the removed built-in MCP feature.
|
||||||
|
//!
|
||||||
|
//! Older releases created a per-project user-defined bridge network
|
||||||
|
//! (`triple-c-net-<projectId>`) plus one container per Docker-backed MCP
|
||||||
|
//! server, and attached the project container to that network. Now that MCP
|
||||||
|
//! support is gone, those leftovers have to be torn down — a container whose
|
||||||
|
//! `NetworkMode` names a network that no longer exists refuses to start, so
|
||||||
|
//! the cleanup is paired with a forced container recreation (see
|
||||||
|
//! `container_needs_recreation`).
|
||||||
|
//!
|
||||||
|
//! Everything here is best-effort: failures are logged and never abort the
|
||||||
|
//! caller, and absent resources are a silent no-op. This module can be deleted
|
||||||
|
//! a release after all users have migrated.
|
||||||
|
|
||||||
|
use bollard::container::{ListContainersOptions, RemoveContainerOptions};
|
||||||
|
use bollard::network::InspectNetworkOptions;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// Network name used by the old MCP implementation for a project.
|
||||||
|
fn legacy_network_name(project_id: &str) -> String {
|
||||||
|
format!("triple-c-net-{}", project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-remove every leftover MCP server container.
|
||||||
|
///
|
||||||
|
/// Matched by the `triple-c.mcp-server` label rather than by name, so
|
||||||
|
/// containers survive even if the MCP server definitions they came from are
|
||||||
|
/// already gone from storage. Best-effort: errors are logged and skipped.
|
||||||
|
pub async fn remove_legacy_mcp_containers(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy MCP container cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"label".to_string(),
|
||||||
|
vec!["triple-c.mcp-server".to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = match docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to list legacy MCP containers: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
let Some(id) = container.id else { continue };
|
||||||
|
match docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::info!("Removed legacy MCP container {}", id),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the old per-project Docker network, disconnecting any remaining
|
||||||
|
/// members first (a network with attached endpoints cannot be deleted).
|
||||||
|
///
|
||||||
|
/// Silent no-op when the network does not exist. Best-effort: errors are
|
||||||
|
/// logged and never propagated.
|
||||||
|
pub async fn remove_legacy_project_network(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy network cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let network_name = legacy_network_name(project_id);
|
||||||
|
|
||||||
|
// Inspect to discover connected containers; absence means nothing to do.
|
||||||
|
let info = match docker
|
||||||
|
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("Legacy network {} not present, nothing to do", network_name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(containers) = info.containers {
|
||||||
|
for container_id in containers.into_keys() {
|
||||||
|
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
|
||||||
|
container: container_id.clone(),
|
||||||
|
force: true,
|
||||||
|
};
|
||||||
|
if let Err(e) = docker
|
||||||
|
.disconnect_network(&network_name, disconnect_opts)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to disconnect container {} from legacy network {}: {}",
|
||||||
|
container_id,
|
||||||
|
network_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match docker.remove_network(&network_name).await {
|
||||||
|
Ok(_) => log::info!("Removed legacy Docker network {}", network_name),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
|
pub mod ca_certs;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod container;
|
pub mod container;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod exec;
|
pub mod exec;
|
||||||
|
pub mod gateway;
|
||||||
|
pub mod legacy_cleanup;
|
||||||
|
pub mod migration;
|
||||||
|
pub mod stt;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use gateway::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use stt::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use client::*;
|
pub use client::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use container::*;
|
pub use container::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use image::*;
|
pub use image::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use legacy_cleanup::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use migration::*;
|
||||||
|
// Deliberately *not* re-exported flat: `ca_certs::resolve` and
|
||||||
|
// `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that
|
||||||
|
// already re-exports five other namespaces.
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
use bollard::container::{
|
||||||
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
|
StartContainerOptions, StopContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::image::BuildImageOptions;
|
||||||
|
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
use crate::models::app_settings::{SttSettings, SttStatus};
|
||||||
|
|
||||||
|
const STT_CONTAINER_NAME: &str = "triple-c-stt";
|
||||||
|
const STT_MODEL_VOLUME: &str = "triple-c-stt-model-cache";
|
||||||
|
const STT_REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-stt:latest";
|
||||||
|
const STT_LOCAL_IMAGE: &str = "triple-c-stt:latest";
|
||||||
|
const STT_DOCKERFILE: &str = include_str!("../../../../stt-container/Dockerfile");
|
||||||
|
const STT_SERVER: &str = include_str!("../../../../stt-container/server.py");
|
||||||
|
|
||||||
|
pub async fn get_stt_status(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||||
|
let image_exists = super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false)
|
||||||
|
|| super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false);
|
||||||
|
|
||||||
|
let (container_exists, running, model) = match find_stt_container().await? {
|
||||||
|
Some((_, state, env_model)) => (true, state == "running", env_model),
|
||||||
|
None => (false, false, settings.model.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SttStatus {
|
||||||
|
container_exists,
|
||||||
|
running,
|
||||||
|
port: settings.port,
|
||||||
|
model,
|
||||||
|
image_exists,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_stt_container() -> Result<Option<(String, String, String)>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"name".to_string(),
|
||||||
|
vec![format!("/{}", STT_CONTAINER_NAME)],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(container) = containers.first() {
|
||||||
|
let id = container.id.clone().unwrap_or_default();
|
||||||
|
let state = container.state.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract WHISPER_MODEL from container env
|
||||||
|
let model = container
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.get("triple-c.stt.model"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "tiny".to_string());
|
||||||
|
|
||||||
|
return Ok(Some((id, state, model)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_stt_container(settings: &SttSettings) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Try local image first, fall back to registry
|
||||||
|
let image = if super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false) {
|
||||||
|
STT_LOCAL_IMAGE.to_string()
|
||||||
|
} else if super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false) {
|
||||||
|
STT_REGISTRY_IMAGE.to_string()
|
||||||
|
} else {
|
||||||
|
return Err("STT image not found. Please build or pull the image first.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let port_binding = PortBinding {
|
||||||
|
host_ip: Some("127.0.0.1".to_string()),
|
||||||
|
host_port: Some(settings.port.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut port_bindings = HashMap::new();
|
||||||
|
port_bindings.insert(
|
||||||
|
"9876/tcp".to_string(),
|
||||||
|
Some(vec![port_binding]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let host_config = HostConfig {
|
||||||
|
port_bindings: Some(port_bindings),
|
||||||
|
mounts: Some(vec![Mount {
|
||||||
|
target: Some("/root/.cache/huggingface".to_string()),
|
||||||
|
source: Some(STT_MODEL_VOLUME.to_string()),
|
||||||
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.stt.model".to_string(),
|
||||||
|
settings.model.clone(),
|
||||||
|
);
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.stt.port".to_string(),
|
||||||
|
settings.port.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
image: Some(image),
|
||||||
|
env: Some(vec![format!("WHISPER_MODEL={}", settings.model)]),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
labels: Some(labels),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CreateContainerOptions {
|
||||||
|
name: STT_CONTAINER_NAME,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = docker
|
||||||
|
.create_container(Some(options), config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create STT container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_stt_running(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Check if container exists and if settings match
|
||||||
|
if let Some((id, state, model)) = find_stt_container().await? {
|
||||||
|
let needs_recreate = model != settings.model;
|
||||||
|
|
||||||
|
if needs_recreate {
|
||||||
|
// Settings changed, recreate
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(&id, None::<StopContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||||
|
}
|
||||||
|
docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to remove STT container: {}", e))?;
|
||||||
|
} else if state == "running" {
|
||||||
|
return get_stt_status(settings).await;
|
||||||
|
} else {
|
||||||
|
// Container exists but stopped, start it
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||||
|
return get_stt_status(settings).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and start new container
|
||||||
|
let id = create_stt_container(settings).await?;
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||||
|
|
||||||
|
get_stt_status(settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_stt_container() -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
if let Some((id, state, _)) = find_stt_container().await? {
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(&id, None::<StopContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
super::image::pull_image(STT_REGISTRY_IMAGE, on_progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let tar_bytes = create_stt_build_context()
|
||||||
|
.map_err(|e| format!("Failed to create STT build context: {}", e))?;
|
||||||
|
|
||||||
|
let options = BuildImageOptions {
|
||||||
|
t: STT_LOCAL_IMAGE,
|
||||||
|
rm: true,
|
||||||
|
forcerm: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||||
|
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(output) => {
|
||||||
|
if let Some(stream) = output.stream {
|
||||||
|
on_progress(stream);
|
||||||
|
}
|
||||||
|
if let Some(error) = output.error {
|
||||||
|
return Err(format!("Build error: {}", error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_stt_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
|
let mut dockerfile_header = tar::Header::new_gnu();
|
||||||
|
dockerfile_header.set_size(STT_DOCKERFILE.len() as u64);
|
||||||
|
dockerfile_header.set_mode(0o644);
|
||||||
|
dockerfile_header.set_cksum();
|
||||||
|
archive.append_data(&mut dockerfile_header, "Dockerfile", STT_DOCKERFILE.as_bytes())?;
|
||||||
|
|
||||||
|
let mut server_header = tar::Header::new_gnu();
|
||||||
|
server_header.set_size(STT_SERVER.len() as u64);
|
||||||
|
server_header.set_mode(0o644);
|
||||||
|
server_header.set_cksum();
|
||||||
|
archive.append_data(&mut server_header, "server.py", STT_SERVER.as_bytes())?;
|
||||||
|
|
||||||
|
archive.finish()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = buf.flush();
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
|
||||||
|
// installed on the host and, when missing, offering to install it for the user.
|
||||||
|
//
|
||||||
|
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
|
||||||
|
// Windows. On every platform we also surface an official documentation URL so
|
||||||
|
// users without a recognised package manager can install manually.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod platform;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InstallOptions {
|
||||||
|
/// "linux" | "macos" | "windows" | "unknown"
|
||||||
|
pub os: String,
|
||||||
|
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
|
||||||
|
pub product_name: String,
|
||||||
|
/// Whether we can kick off a one-click install with what's on this machine.
|
||||||
|
pub can_auto_install: bool,
|
||||||
|
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
|
||||||
|
pub auto_install_method: Option<String>,
|
||||||
|
/// If auto-install isn't possible, a human-readable reason to show the user.
|
||||||
|
pub auto_install_blocker: Option<String>,
|
||||||
|
/// Official documentation URL for manual install.
|
||||||
|
pub docs_url: String,
|
||||||
|
/// Ordered manual install steps (plain text lines).
|
||||||
|
pub manual_steps: Vec<String>,
|
||||||
|
/// Notes to display after a successful auto-install (e.g. log out/back in).
|
||||||
|
pub post_install_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_install_options() -> InstallOptions {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
platform::linux_options()
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
platform::macos_options()
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
platform::windows_options()
|
||||||
|
} else {
|
||||||
|
InstallOptions {
|
||||||
|
os: "unknown".into(),
|
||||||
|
product_name: "Docker".into(),
|
||||||
|
can_auto_install: false,
|
||||||
|
auto_install_method: None,
|
||||||
|
auto_install_blocker: Some("Unsupported operating system".into()),
|
||||||
|
docs_url: "https://docs.docker.com/get-docker/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Visit the Docker documentation and follow the install guide for your OS.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::InstallOptions;
|
||||||
|
|
||||||
|
const PROGRESS_EVENT: &str = "docker-install-progress";
|
||||||
|
|
||||||
|
fn which(cmd: &str) -> bool {
|
||||||
|
find_on_path(cmd).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search PATH for an executable, plus a handful of well-known locations that
|
||||||
|
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
|
||||||
|
fn find_on_path(cmd: &str) -> Option<PathBuf> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let extra: &[&str] = &[
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/bin",
|
||||||
|
];
|
||||||
|
#[cfg(windows)]
|
||||||
|
let extra: &[&str] = &[];
|
||||||
|
|
||||||
|
if let Ok(path) = std::env::var("PATH") {
|
||||||
|
let sep = if cfg!(windows) { ';' } else { ':' };
|
||||||
|
for dir in path.split(sep).chain(extra.iter().copied()) {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
for ext in ["exe", "cmd", "bat"] {
|
||||||
|
let mut with_ext = candidate.clone();
|
||||||
|
with_ext.set_extension(ext);
|
||||||
|
if with_ext.is_file() {
|
||||||
|
return Some(with_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in extra {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
let app_out = app.clone();
|
||||||
|
let out_task = tokio::spawn(async move {
|
||||||
|
if let Some(out) = stdout {
|
||||||
|
let mut lines = BufReader::new(out).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_out.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_err = app.clone();
|
||||||
|
let err_task = tokio::spawn(async move {
|
||||||
|
if let Some(err) = stderr {
|
||||||
|
let mut lines = BufReader::new(err).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_err.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("install process failed: {}", e))?;
|
||||||
|
let _ = out_task.await;
|
||||||
|
let _ = err_task.await;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"installer exited with status {}",
|
||||||
|
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Linux ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn linux_options() -> InstallOptions {
|
||||||
|
let has_pkexec = which("pkexec");
|
||||||
|
let has_curl = which("curl");
|
||||||
|
|
||||||
|
let (can_auto, blocker) = match (has_pkexec, has_curl) {
|
||||||
|
(true, true) => (true, None),
|
||||||
|
(false, _) => (
|
||||||
|
false,
|
||||||
|
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
(_, false) => (
|
||||||
|
false,
|
||||||
|
Some("curl not found — install curl or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
InstallOptions {
|
||||||
|
os: "linux".into(),
|
||||||
|
product_name: "Docker Engine".into(),
|
||||||
|
can_auto_install: can_auto,
|
||||||
|
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
|
||||||
|
auto_install_blocker: blocker,
|
||||||
|
docs_url: "https://docs.docker.com/engine/install/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Open a terminal.".into(),
|
||||||
|
"Run: curl -fsSL https://get.docker.com | sh".into(),
|
||||||
|
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
|
||||||
|
"Log out and log back in for group changes to take effect.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
|
||||||
|
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
// Grab the current username so pkexec (which runs as root) can add the
|
||||||
|
// original invoking user to the docker group.
|
||||||
|
let invoking_user = std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("LOGNAME"))
|
||||||
|
.map_err(|_| "could not determine invoking username".to_string())?;
|
||||||
|
|
||||||
|
// Write a self-contained installer script to a temp file. Running the
|
||||||
|
// Docker convenience script then appending the user to the docker group
|
||||||
|
// and enabling the service.
|
||||||
|
let script = format!(
|
||||||
|
r#"#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "[triple-c] Downloading Docker install script..."
|
||||||
|
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Running Docker install script (may take a few minutes)..."
|
||||||
|
sh /tmp/triple-c-get-docker.sh
|
||||||
|
rm -f /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Adding {user} to docker group..."
|
||||||
|
usermod -aG docker "{user}" || true
|
||||||
|
echo "[triple-c] Enabling docker service..."
|
||||||
|
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
|
||||||
|
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
|
||||||
|
"#,
|
||||||
|
user = invoking_user
|
||||||
|
);
|
||||||
|
|
||||||
|
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
|
||||||
|
tokio::fs::write(&script_path, script)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to write install script: {}", e))?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Requesting administrator privileges via pkexec..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
let child = Command::new("pkexec")
|
||||||
|
.arg("sh")
|
||||||
|
.arg(&script_path)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
|
||||||
|
|
||||||
|
let result = stream(app, child).await;
|
||||||
|
let _ = tokio::fs::remove_file(&script_path).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── macOS ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn macos_options() -> InstallOptions {
|
||||||
|
let has_brew = which("brew");
|
||||||
|
InstallOptions {
|
||||||
|
os: "macos".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_brew,
|
||||||
|
auto_install_method: if has_brew { Some("brew".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_brew {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Homebrew not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .dmg from the official site.".into(),
|
||||||
|
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker socket is available, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
|
||||||
|
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let brew = find_on_path("brew")
|
||||||
|
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Running: {} install --cask rancher", brew.display()),
|
||||||
|
);
|
||||||
|
let child = Command::new(&brew)
|
||||||
|
.args(["install", "--cask", "rancher"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch brew: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Windows ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn windows_options() -> InstallOptions {
|
||||||
|
let has_winget = which("winget");
|
||||||
|
InstallOptions {
|
||||||
|
os: "windows".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_winget,
|
||||||
|
auto_install_method: if has_winget { Some("winget".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_winget {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("winget not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .msi from the official site.".into(),
|
||||||
|
"Run the installer and accept the WSL2 prompts if asked.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker engine is running, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
|
||||||
|
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
|
||||||
|
);
|
||||||
|
let child = Command::new("winget")
|
||||||
|
.args([
|
||||||
|
"install",
|
||||||
|
"--id",
|
||||||
|
"SUSE.RancherDesktop",
|
||||||
|
"-e",
|
||||||
|
"--accept-package-agreements",
|
||||||
|
"--accept-source-agreements",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch winget: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
run_linux_install(app).await
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
run_macos_install(app).await
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
run_windows_install(app).await
|
||||||
|
} else {
|
||||||
|
Err("auto-install is not supported on this OS".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// The level the dispatch is built with, and the level restored by hand if
|
||||||
|
/// installing it fails — see the failure branch in [`init`] for why that
|
||||||
|
/// matters more than it looks.
|
||||||
|
const LOG_LEVEL: log::LevelFilter = log::LevelFilter::Info;
|
||||||
|
|
||||||
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
|
fn log_dir() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise logging to both stderr and a log file in the app data directory.
|
||||||
|
///
|
||||||
|
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
|
||||||
|
/// A panic hook is also installed so that unexpected crashes are captured in the
|
||||||
|
/// same log file before the process exits.
|
||||||
|
pub fn init() {
|
||||||
|
let log_file_path = log_dir().and_then(|dir| {
|
||||||
|
fs::create_dir_all(&dir).ok()?;
|
||||||
|
let path = dir.join("triple-c.log");
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| (path, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut dispatch = fern::Dispatch::new()
|
||||||
|
.format(|out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{} {} {}] {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.level(LOG_LEVEL)
|
||||||
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
|
if let Some((_path, file)) = &log_file_path {
|
||||||
|
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = dispatch.apply() {
|
||||||
|
// H2's other half. `fern::Dispatch::apply` calls `log::set_boxed_logger`
|
||||||
|
// and only then `log::set_max_level`, so a failure returns with the
|
||||||
|
// global filter still at its default, `LevelFilter::Off`. That is not
|
||||||
|
// merely "no log output": every `log::info!(…)` expands to
|
||||||
|
// `if Info <= max_level() { … }`, so at `Off` the macro never evaluates
|
||||||
|
// its own arguments. Anything a call site put in an argument list —
|
||||||
|
// a function call, an `await`, a side effect — silently stops
|
||||||
|
// happening, app-wide, because a logger could not be installed.
|
||||||
|
//
|
||||||
|
// Call sites must not put effects in log arguments (see the
|
||||||
|
// pre-migration scrub in `migration_commands.rs`), but "the whole
|
||||||
|
// program's log macros are dead and nothing said so" is its own
|
||||||
|
// hazard, so the level this dispatch was configured with is restored
|
||||||
|
// by hand. Nothing is listening — `log`'s default logger is a no-op —
|
||||||
|
// but the macros evaluate, and the one thing that *is* guaranteed to
|
||||||
|
// reach the user, the stderr line below, says what happened.
|
||||||
|
eprintln!(
|
||||||
|
"Failed to initialise logger: {}. Log output is disabled for this run; \
|
||||||
|
log macros still evaluate their arguments.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
log::set_max_level(LOG_LEVEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install a panic hook that writes to the log file so crashes are captured.
|
||||||
|
let crash_log_dir = log_dir();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let msg = format!(
|
||||||
|
"[{} PANIC] {}\nBacktrace:\n{:?}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
info,
|
||||||
|
std::backtrace::Backtrace::force_capture(),
|
||||||
|
);
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(ref dir) = crash_log_dir {
|
||||||
|
let crash_path = dir.join("triple-c.log");
|
||||||
|
let _ = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&crash_path)
|
||||||
|
.and_then(|mut f| {
|
||||||
|
use std::io::Write;
|
||||||
|
writeln!(f, "{}", msg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Some((ref path, _)) = log_file_path {
|
||||||
|
log::info!("Logging to {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_logger_that_could_not_be_installed_still_leaves_the_macros_evaluating() {
|
||||||
|
// H2: `log::info!(…)` expands to `if Info <= max_level() { … }`, so at
|
||||||
|
// `LevelFilter::Off` the arguments are never evaluated. `fern` returns
|
||||||
|
// before `set_max_level` when `apply()` fails, which leaves exactly
|
||||||
|
// that state — and a call site that folded an effect into an argument
|
||||||
|
// list then stops performing it, app-wide, because a log file could not
|
||||||
|
// be opened. The failure branch restores the level for that reason.
|
||||||
|
//
|
||||||
|
// Asserted on the level itself rather than by driving `init`, which
|
||||||
|
// installs a process-global logger and a panic hook and can only run
|
||||||
|
// once per process.
|
||||||
|
assert_ne!(LOG_LEVEL, log::LevelFilter::Off);
|
||||||
|
|
||||||
|
// The property that makes the above worth asserting, demonstrated
|
||||||
|
// against the macro itself: a side effect in an argument list runs only
|
||||||
|
// while the level admits the record.
|
||||||
|
let mut ran = false;
|
||||||
|
let effect = |v: &mut bool| {
|
||||||
|
*v = true;
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let previous = log::max_level();
|
||||||
|
log::set_max_level(log::LevelFilter::Off);
|
||||||
|
log::info!("{}", effect(&mut ran));
|
||||||
|
assert!(!ran, "the premise is wrong: arguments evaluated at LevelFilter::Off");
|
||||||
|
log::set_max_level(LOG_LEVEL);
|
||||||
|
log::info!("{}", effect(&mut ran));
|
||||||
|
assert!(ran, "arguments did not evaluate at the level this module configures");
|
||||||
|
log::set_max_level(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,157 @@
|
|||||||
// Prevents additional console window on Windows in release
|
// Prevents additional console window on Windows in release
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||||
|
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||||
|
/// under Wayland, killing the webview and leaving a blank window — see
|
||||||
|
/// triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||||
|
///
|
||||||
|
/// **This is not the only cause of a blank window, and the error text alone
|
||||||
|
/// does not tell them apart.** An earlier version of this comment quoted
|
||||||
|
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
|
||||||
|
/// the error this fixes. The AppImage produces that same string for an
|
||||||
|
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
|
||||||
|
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
|
||||||
|
/// DT_NEEDED on that library, so the EGL driver failed to load before any
|
||||||
|
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
|
||||||
|
/// which cost a round of debugging that started from the comment rather than
|
||||||
|
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
|
||||||
|
///
|
||||||
|
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||||
|
/// variable is exported into an XWayland client's environment too, so a
|
||||||
|
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||||
|
/// there is no reliable heuristic at all for the actual variable that
|
||||||
|
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||||
|
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||||
|
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||||
|
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||||
|
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||||
|
/// setting — slower on very heavy output, but the addon's own construction
|
||||||
|
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||||
|
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||||
|
/// against a startup abort that has no fallback at all.
|
||||||
|
///
|
||||||
|
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||||
|
/// their own init time, which happens inside the Tauri builder that
|
||||||
|
/// function calls into, not at binary load.
|
||||||
|
///
|
||||||
|
/// A user who has already set this themselves is left alone — with one
|
||||||
|
/// correction. The earlier version of this function left *any* pre-set value
|
||||||
|
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||||
|
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||||
|
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||||
|
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||||
|
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||||
|
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||||
|
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||||
|
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||||
|
/// sees any difference.
|
||||||
|
///
|
||||||
|
/// That matters more than it looks, because the trade described above is not
|
||||||
|
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||||
|
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||||
|
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||||
|
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||||
|
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||||
|
/// this comment assumed it would degrade to, not faster. See
|
||||||
|
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||||
|
/// whether the addon is loaded at all.
|
||||||
|
///
|
||||||
|
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||||
|
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||||
|
/// call. Narrow in practice (an already-running browser just receives the
|
||||||
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
|
/// as a separate, unrelated cause.
|
||||||
|
///
|
||||||
|
/// That leak is now plugged rather than merely documented: `url_open` hands
|
||||||
|
/// the opener a child environment with this variable (and the AppImage's own
|
||||||
|
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
|
||||||
|
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
|
||||||
|
/// children no longer inherit it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||||
|
|
||||||
|
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||||
|
/// already set to. Split from the mutation so it can be tested without
|
||||||
|
/// touching process-wide environment state from a parallel test runner.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum DmabufAction {
|
||||||
|
/// Not set by the user — apply the workaround.
|
||||||
|
Disable,
|
||||||
|
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||||
|
/// way to express "enabled" is for the variable not to exist.
|
||||||
|
Remove,
|
||||||
|
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||||
|
LeaveAlone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||||
|
match current {
|
||||||
|
None => DmabufAction::Disable,
|
||||||
|
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||||
|
_ => DmabufAction::LeaveAlone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn apply_webkit_wayland_workaround() {
|
||||||
|
let current = std::env::var(DMABUF_VAR).ok();
|
||||||
|
match dmabuf_action(current.as_deref()) {
|
||||||
|
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||||
|
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||||
|
DmabufAction::LeaveAlone => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::{dmabuf_action, DmabufAction};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_gets_the_workaround() {
|
||||||
|
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||||
|
// The bug this replaces: these all previously read as "user set it,
|
||||||
|
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||||
|
// it only checks presence. There was no way to ask for the GPU path.
|
||||||
|
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::Remove,
|
||||||
|
"{value:?} should opt out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_values_are_left_alone() {
|
||||||
|
for value in ["1", "true", "yes", "anything"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::LeaveAlone,
|
||||||
|
"{value:?} should be left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||||
|
// environment this app was started with, and the workaround below is one
|
||||||
|
// of the things that must not leak into it (see triple-c#34). Anything
|
||||||
|
// added here that mutates the environment belongs after this line.
|
||||||
|
triple_c_lib::url_open::capture_pristine_environment();
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
apply_webkit_wayland_workaround();
|
||||||
|
|
||||||
triple_c_lib::run()
|
triple_c_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::gateway_settings::GatewaySettings;
|
||||||
|
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_global_instructions() -> Option<String> {
|
||||||
|
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImageSource {
|
pub enum ImageSource {
|
||||||
@@ -26,6 +33,8 @@ pub struct GlobalAwsSettings {
|
|||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aws_region: Option<String>,
|
pub aws_region: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GlobalAwsSettings {
|
impl Default for GlobalAwsSettings {
|
||||||
@@ -34,14 +43,58 @@ impl Default for GlobalAwsSettings {
|
|||||||
aws_config_path: None,
|
aws_config_path: None,
|
||||||
aws_profile: None,
|
aws_profile: None,
|
||||||
aws_region: None,
|
aws_region: None,
|
||||||
|
default_model_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GlobalOllamaSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
|
/// Global fallback for the `haiku` alias override. Blank means "use the
|
||||||
|
/// resolved model id", which is what makes background Claude Code calls
|
||||||
|
/// work against a server that only serves one model.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_haiku_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global defaults for the llama.cpp (`llama-server`) backend.
|
||||||
|
/// Mirrors [`GlobalOllamaSettings`]; used when the per-project field is blank.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GlobalLlamaCppSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_haiku_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GlobalOpenAiCompatibleSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_haiku_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_ssh_key_path: Option<String>,
|
pub default_ssh_key_path: Option<String>,
|
||||||
|
/// Path to the organisation's root CA — a single certificate file or a
|
||||||
|
/// directory of them. Mounted read-only into every container, which then
|
||||||
|
/// installs it into the system trust store, Node's `NODE_EXTRA_CA_CERTS`,
|
||||||
|
/// Python's `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database.
|
||||||
|
/// Required when the host sits behind a TLS-terminating corporate proxy.
|
||||||
|
/// Overridden per project by `Project::ca_cert_path`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ca_cert_path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_git_user_name: Option<String>,
|
pub default_git_user_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -55,26 +108,145 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub global_ollama: GlobalOllamaSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_llamacpp: GlobalLlamaCppSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
|
||||||
|
#[serde(default = "default_global_instructions")]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dismissed_update_version: Option<String>,
|
pub dismissed_update_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_microphone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dismissed_image_digest: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_terminal: WebTerminalSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stt: SttSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway: GatewaySettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||||
|
///
|
||||||
|
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||||
|
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||||
|
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||||
|
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||||
|
/// The addon therefore loads successfully and then renders every frame on
|
||||||
|
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||||
|
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||||
|
/// disabled on Linux.
|
||||||
|
///
|
||||||
|
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||||
|
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||||
|
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||||
|
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||||
|
/// the zero value has to mean "we choose", not "off", or every existing
|
||||||
|
/// settings file would silently pin the answer at whatever the default was
|
||||||
|
/// the day it was written.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal_gpu_rendering: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stt_model() -> String {
|
||||||
|
"tiny".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stt_port() -> u16 {
|
||||||
|
9876
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SttSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_stt_model")]
|
||||||
|
pub model: String,
|
||||||
|
#[serde(default = "default_stt_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SttSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
model: default_stt_model(),
|
||||||
|
port: 9876,
|
||||||
|
language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SttStatus {
|
||||||
|
pub container_exists: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub model: String,
|
||||||
|
pub image_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_web_terminal_port() -> u16 {
|
||||||
|
7681
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebTerminalSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_web_terminal_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebTerminalSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
port: 7681,
|
||||||
|
access_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_ssh_key_path: None,
|
default_ssh_key_path: None,
|
||||||
|
ca_cert_path: None,
|
||||||
default_git_user_name: None,
|
default_git_user_name: None,
|
||||||
default_git_user_email: None,
|
default_git_user_email: None,
|
||||||
docker_socket_path: None,
|
docker_socket_path: None,
|
||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
global_claude_instructions: None,
|
global_ollama: GlobalOllamaSettings::default(),
|
||||||
|
global_llamacpp: GlobalLlamaCppSettings::default(),
|
||||||
|
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
|
||||||
|
global_claude_instructions: default_global_instructions(),
|
||||||
|
global_custom_env_vars: Vec::new(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
|
timezone: None,
|
||||||
|
default_microphone: None,
|
||||||
|
dismissed_image_digest: None,
|
||||||
|
web_terminal: WebTerminalSettings::default(),
|
||||||
|
stt: SttSettings::default(),
|
||||||
|
gateway: GatewaySettings::default(),
|
||||||
|
global_claude_code_settings: None,
|
||||||
|
terminal_gpu_rendering: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub struct ContainerInfo {
|
|||||||
|
|
||||||
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
||||||
pub const IMAGE_TAG: &str = "latest";
|
pub const IMAGE_TAG: &str = "latest";
|
||||||
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
|
||||||
|
|
||||||
pub fn local_build_image_name() -> String {
|
pub fn local_build_image_name() -> String {
|
||||||
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! Settings and status for the **model gateway** — a LiteLLM proxy container
|
||||||
|
//! Triple-C runs as a sibling of the project containers.
|
||||||
|
//!
|
||||||
|
//! Claude Code speaks only the Anthropic Messages API (`POST
|
||||||
|
//! ${ANTHROPIC_BASE_URL}/v1/messages`). OpenAI has no such route, so an OpenAI
|
||||||
|
//! key cannot drive Claude Code directly. The gateway exposes `/v1/messages`
|
||||||
|
//! in Anthropic format and translates each call to the configured provider,
|
||||||
|
//! which is what turns "OpenAI Compatible" from *bring your own proxy* into
|
||||||
|
//! something Triple-C manages itself.
|
||||||
|
//!
|
||||||
|
//! Nothing secret lives in this module. The provider API key and the gateway's
|
||||||
|
//! own master key are held in the OS keychain (see `storage::secure`); what is
|
||||||
|
//! persisted to `settings.json` is only the non-secret shape of the config.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// LiteLLM's own default port, and the one the existing "OpenAI Compatible"
|
||||||
|
/// placeholder text already suggests.
|
||||||
|
pub fn default_gateway_port() -> u16 {
|
||||||
|
4000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gateway_provider() -> String {
|
||||||
|
"openai".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of LiteLLM's `model_list`.
|
||||||
|
///
|
||||||
|
/// `name` is the friendly handle a project puts in its model field — it is what
|
||||||
|
/// Claude Code sends as the `model` of a `/v1/messages` request. `model_id` is
|
||||||
|
/// the provider-side id. The gateway config composes them as
|
||||||
|
/// `<provider>/<model_id>`, which is why the shape stays generic across
|
||||||
|
/// providers instead of hard-coding OpenAI.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct GatewayModel {
|
||||||
|
/// Friendly name projects use (e.g. `gpt-5.1`).
|
||||||
|
pub name: String,
|
||||||
|
/// Provider-side model id (e.g. `gpt-5.1`).
|
||||||
|
pub model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GatewaySettings {
|
||||||
|
/// Auto-start the gateway container with the app.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Host port the gateway is published on.
|
||||||
|
#[serde(default = "default_gateway_port")]
|
||||||
|
pub port: u16,
|
||||||
|
/// LiteLLM provider prefix — `openai`, `azure`, `gemini`, `groq`, …
|
||||||
|
#[serde(default = "default_gateway_provider")]
|
||||||
|
pub provider: String,
|
||||||
|
/// Optional provider base URL override (Azure endpoints, proxies, …).
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_base: Option<String>,
|
||||||
|
/// Models the gateway should serve.
|
||||||
|
#[serde(default)]
|
||||||
|
pub models: Vec<GatewayModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GatewaySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
port: default_gateway_port(),
|
||||||
|
provider: default_gateway_provider(),
|
||||||
|
api_base: None,
|
||||||
|
models: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewaySettings {
|
||||||
|
/// Models with both fields filled in. Half-typed rows in the UI must not
|
||||||
|
/// reach the generated YAML.
|
||||||
|
pub fn valid_models(&self) -> Vec<&GatewayModel> {
|
||||||
|
self.models
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !m.name.trim().is_empty() && !m.model_id.trim().is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the settings UI needs to know about the gateway. Deliberately carries
|
||||||
|
/// **no** secret: `has_api_key` is a boolean, not the key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GatewayStatus {
|
||||||
|
pub container_exists: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub image_exists: bool,
|
||||||
|
/// Number of fully-specified models in the current settings.
|
||||||
|
pub model_count: usize,
|
||||||
|
/// Whether a provider API key is present in the keychain.
|
||||||
|
pub has_api_key: bool,
|
||||||
|
/// The value a project should use for its base URL. See
|
||||||
|
/// `docker::gateway::gateway_base_url`.
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
//! Contract types for **container base-image migration**.
|
||||||
|
//!
|
||||||
|
//! ## Why this exists
|
||||||
|
//!
|
||||||
|
//! A project's container is created from `triple-c-snapshot-<id>:latest`
|
||||||
|
//! whenever that image exists, and every recreation re-commits it. Nothing ever
|
||||||
|
//! moved a project back onto a *newer base image*: `container_needs_recreation`
|
||||||
|
//! compared the container's actual image against the `triple-c.image` label that
|
||||||
|
//! `create_container` wrote from the very image it created from — a tautology
|
||||||
|
//! that could never fire. So a project stayed pinned to its own snapshot
|
||||||
|
//! lineage forever and never picked up base-image fixes (a new `socat`, a new
|
||||||
|
//! `/usr/local/bin` shim, security updates). The only escape was Reset, which
|
||||||
|
//! deletes both named volumes and takes the login, the skills and every session
|
||||||
|
//! transcript with it.
|
||||||
|
//!
|
||||||
|
//! Migration is the non-destructive alternative: recreate the container from the
|
||||||
|
//! current base, then replay onto it the small set of things the base does not
|
||||||
|
//! carry, and leave the volumes strictly alone.
|
||||||
|
//!
|
||||||
|
//! ## What actually needs replaying
|
||||||
|
//!
|
||||||
|
//! `/home/claude` is the named volume `triple-c-home-<id>`, with
|
||||||
|
//! `/home/claude/.claude` nested inside it. The image's own `/home/claude` is
|
||||||
|
//! **seed-only** — once the volume is mounted the image's copy is masked
|
||||||
|
//! permanently. So Claude Code itself (it installs to `~/.local/bin`), cargo,
|
||||||
|
//! uv, ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler
|
||||||
|
//! tasks and SSH keys all re-attach for free across an image swap.
|
||||||
|
//!
|
||||||
|
//! What is genuinely lost is confined to the container's writable layer:
|
||||||
|
//! root-level `apt` installs, `npm -g` packages (npm's prefix is `/usr`),
|
||||||
|
//! `/usr/local`, `/opt`, `/srv`, anything under `/workspace` that is not on a
|
||||||
|
//! bind mount — and **`/var`**. The first four are what [`MigrationOptions`]
|
||||||
|
//! can replay. `/var` is not, and that gap is deliberate rather than an
|
||||||
|
//! oversight, so it is stated here rather than glossed over:
|
||||||
|
//!
|
||||||
|
//! Service state lives in `/var/lib/<service>` and `/var/www`. Replaying the
|
||||||
|
//! apt delta reinstalls `postgresql` onto the new base and hands back an
|
||||||
|
//! **empty** cluster; the old one is gone with the writable layer. The
|
||||||
|
//! ordinary recreate path does not have this problem, because it creates from
|
||||||
|
//! the project's own snapshot and `/var` rides along — so a silent migration
|
||||||
|
//! would be *more* destructive than the thing it is sold as a safer
|
||||||
|
//! alternative to.
|
||||||
|
//!
|
||||||
|
//! Copying a live database's files out with `tar` and unpacking them onto a
|
||||||
|
//! different base's version of the same package is not a fix; it is a
|
||||||
|
//! corruption risk wearing a fix's clothes. So the answer is disclosure:
|
||||||
|
//! [`crate::docker::migration::unpreserved_data`] finds the data-bearing
|
||||||
|
//! subtrees under `/var` that the base does not ship, and
|
||||||
|
//! [`ContainerStaleness::unpreserved_data`] carries them into the pre-flight,
|
||||||
|
//! where the user is told to back them up before anything is touched.
|
||||||
|
//!
|
||||||
|
//! ## Serde
|
||||||
|
//!
|
||||||
|
//! Plain snake_case, matching every other IPC struct in this crate
|
||||||
|
//! (`ContainerInfo`, `ClaudeSession`, …) and `app/src/lib/types.ts`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// How a finished migration attempt ended.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MigrationPhase {
|
||||||
|
/// The container now runs on the current base and everything requested was
|
||||||
|
/// replayed.
|
||||||
|
Succeeded,
|
||||||
|
/// The container now runs on the current base, but at least one package or
|
||||||
|
/// path could not be replayed. Deliberately distinct from `Failed`: one
|
||||||
|
/// missing apt package must never cost the user the whole migration.
|
||||||
|
Partial,
|
||||||
|
/// The migration could not complete. If the container had already been
|
||||||
|
/// swapped, an automatic rollback was attempted — check
|
||||||
|
/// [`MigrationReport::rollback_available`] and the message.
|
||||||
|
Failed,
|
||||||
|
/// The migration was undone; the container is back on its pre-migration
|
||||||
|
/// snapshot image.
|
||||||
|
RolledBack,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One package that could not be replayed onto the new base.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PackageFailure {
|
||||||
|
pub name: String,
|
||||||
|
/// Trimmed tail of the package manager's own error output.
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A data-bearing subtree the migration will destroy and cannot put back.
|
||||||
|
///
|
||||||
|
/// See [`crate::docker::migration::unpreserved_data`]. Surfaced in the
|
||||||
|
/// pre-flight so the user can take a backup first; never copied.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UnpreservedData {
|
||||||
|
/// Absolute path of the directory, e.g. `/var/lib/postgresql`.
|
||||||
|
pub path: String,
|
||||||
|
/// Total size of the non-package files beneath it.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// How many non-package files it holds.
|
||||||
|
pub file_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the UI needs to decide whether a project is worth migrating, and
|
||||||
|
/// to explain to the user what migrating would actually change.
|
||||||
|
///
|
||||||
|
/// A field being empty always means "nothing found", never "not checked" —
|
||||||
|
/// [`ContainerStaleness::probe_error`] is the single place a failed inspection
|
||||||
|
/// is reported.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ContainerStaleness {
|
||||||
|
/// The container's lineage is not the current base image.
|
||||||
|
/// Always `false` when `known` is `false` — an unknown lineage is not a
|
||||||
|
/// claim of staleness.
|
||||||
|
pub stale: bool,
|
||||||
|
/// Whether the lineage could be established at all. `false` means the
|
||||||
|
/// container (or its snapshot image) predates the `triple-c.base-image-id`
|
||||||
|
/// label, i.e. **"unknown, probe instead"** — never "stale".
|
||||||
|
pub known: bool,
|
||||||
|
/// Image ID of the base this container's lineage descends from.
|
||||||
|
pub base_image_id: Option<String>,
|
||||||
|
/// Image ID of the base image currently configured in settings.
|
||||||
|
pub current_base_image_id: Option<String>,
|
||||||
|
/// `Created` timestamp of the project's snapshot image, RFC 3339.
|
||||||
|
pub snapshot_created_at: Option<String>,
|
||||||
|
/// Concrete paths the current base ships that this container does not,
|
||||||
|
/// e.g. `/usr/bin/socat`.
|
||||||
|
pub missing_paths: Vec<String>,
|
||||||
|
/// Human labels for the same, e.g. `"Auth bridge tunnel (socat)"`.
|
||||||
|
pub missing_features: Vec<String>,
|
||||||
|
/// `apt-mark showmanual` in the container minus the base's own set — the
|
||||||
|
/// packages a migration would replay.
|
||||||
|
pub apt_delta: Vec<String>,
|
||||||
|
/// Globally-installed npm packages the base does not ship.
|
||||||
|
pub npm_global_delta: Vec<String>,
|
||||||
|
/// Non-dpkg-owned paths under the verbatim-copy roots that would be carried
|
||||||
|
/// across. Empty when nothing user-authored was found.
|
||||||
|
pub verbatim_paths: Vec<String>,
|
||||||
|
/// Data-bearing subtrees under `/var` that a migration **destroys and
|
||||||
|
/// cannot restore** — a database's files, a served site. Empty on an
|
||||||
|
/// ordinary container; when it is not, the pre-flight has to say so before
|
||||||
|
/// anything is touched. See [`UnpreservedData`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub unpreserved_data: Vec<UnpreservedData>,
|
||||||
|
/// dpkg packages the current base carries at a different version than this
|
||||||
|
/// container does. A rough "how much security drift" number, not a promise
|
||||||
|
/// that every one of them is newer.
|
||||||
|
pub outdated_package_count: u32,
|
||||||
|
/// Set when the container/image could not be inspected. Everything else is
|
||||||
|
/// then at its default.
|
||||||
|
pub probe_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a migration should replay. All three default to off so that
|
||||||
|
/// `MigrationOptions::default()` is the minimal, fastest migration.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MigrationOptions {
|
||||||
|
/// Replay the apt and `npm -g` deltas onto the new base.
|
||||||
|
#[serde(default)]
|
||||||
|
pub replay_packages: bool,
|
||||||
|
/// Copy the verbatim payload (`/usr/local`, `/opt`, `/srv`, and the
|
||||||
|
/// non-bind-mounted parts of `/workspace`) onto the new base.
|
||||||
|
#[serde(default)]
|
||||||
|
pub copy_paths: bool,
|
||||||
|
/// Keep the `:pre-migration-<ts>` rollback tag after the migration reports
|
||||||
|
/// success. Costs the full size of the old snapshot image (snapshots share
|
||||||
|
/// almost no layers with the current base) but makes rollback instant.
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_rollback: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of one migration attempt.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MigrationReport {
|
||||||
|
pub phase: MigrationPhase,
|
||||||
|
pub packages_requested: Vec<String>,
|
||||||
|
pub packages_installed: Vec<String>,
|
||||||
|
pub packages_failed: Vec<PackageFailure>,
|
||||||
|
pub paths_copied: Vec<String>,
|
||||||
|
/// Human labels for base features the container gained, e.g.
|
||||||
|
/// `"Auth bridge tunnel (socat)"`.
|
||||||
|
pub features_restored: Vec<String>,
|
||||||
|
/// A `:pre-migration-<ts>` image tag still exists, so
|
||||||
|
/// `rollback_migration` can put the old system layer back.
|
||||||
|
pub rollback_available: bool,
|
||||||
|
/// One paragraph fit to show the user verbatim.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationReport {
|
||||||
|
/// A report for a migration that never got past pre-flight. Nothing was
|
||||||
|
/// touched, so there is nothing to roll back.
|
||||||
|
pub fn failed_preflight(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
phase: MigrationPhase::Failed,
|
||||||
|
packages_requested: Vec::new(),
|
||||||
|
packages_installed: Vec::new(),
|
||||||
|
packages_failed: Vec::new(),
|
||||||
|
paths_copied: Vec::new(),
|
||||||
|
features_restored: Vec::new(),
|
||||||
|
rollback_available: false,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a migration decided to do, frozen at pre-flight time.
|
||||||
|
///
|
||||||
|
/// Persisted with the state because a **resume** cannot recompute it: by the
|
||||||
|
/// time the app comes back up the container has already been replaced by one
|
||||||
|
/// created from the base, so its apt/npm sets *are* the base's and the deltas
|
||||||
|
/// would come out empty.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MigrationPlan {
|
||||||
|
pub apt_packages: Vec<String>,
|
||||||
|
pub npm_packages: Vec<String>,
|
||||||
|
pub verbatim_paths: Vec<String>,
|
||||||
|
/// Base-image paths the old container lacked, so the finished migration can
|
||||||
|
/// report which of them it actually gained.
|
||||||
|
pub missing_paths: Vec<String>,
|
||||||
|
/// What the pre-flight found under `/var` that the migration would destroy.
|
||||||
|
/// Frozen here so the finished report can name it even though the container
|
||||||
|
/// it was measured on no longer exists.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unpreserved_data: Vec<UnpreservedData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted, host-side migration state. Written **before** anything
|
||||||
|
/// destructive happens and removed on confirm or rollback, so a crash at any
|
||||||
|
/// point leaves a record of what was in flight.
|
||||||
|
///
|
||||||
|
/// `phase` is a free-form string rather than [`MigrationPhase`] because it also
|
||||||
|
/// carries the *in-flight* phases, which are not outcomes:
|
||||||
|
///
|
||||||
|
/// | `phase` | Meaning | Offered next |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `in-progress` | A migration is running right now | — |
|
||||||
|
/// | `interrupted` | The app died after the container swap | resume, rollback |
|
||||||
|
/// | `awaiting-confirmation` | Migration finished; rollback still possible | confirm, rollback |
|
||||||
|
///
|
||||||
|
/// See [`MIGRATION_PHASE_IN_PROGRESS`] and friends.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MigrationState {
|
||||||
|
pub phase: String,
|
||||||
|
/// Image ID of the snapshot the project was on before the swap.
|
||||||
|
pub from_image_id: Option<String>,
|
||||||
|
/// Image ID of the base being migrated to.
|
||||||
|
pub to_base_id: Option<String>,
|
||||||
|
/// RFC 3339.
|
||||||
|
pub started_at: String,
|
||||||
|
/// Present once the attempt produced one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub report: Option<MigrationReport>,
|
||||||
|
/// The `:pre-migration-<ts>` tag holding the old system layer, if one was
|
||||||
|
/// created. `rollback_migration` retags this back to `:latest`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rollback_image: Option<String>,
|
||||||
|
/// Host path of the staged verbatim payload tar, if one was staged.
|
||||||
|
#[serde(default)]
|
||||||
|
pub staging_path: Option<String>,
|
||||||
|
/// The options the attempt was started with, so a resume replays the same
|
||||||
|
/// things the user originally asked for.
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: MigrationOptions,
|
||||||
|
/// The frozen pre-flight plan. See [`MigrationPlan`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub plan: Option<MigrationPlan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A migration is running in this process right now.
|
||||||
|
pub const MIGRATION_PHASE_IN_PROGRESS: &str = "in-progress";
|
||||||
|
/// The app died after the container swap but before the final commit.
|
||||||
|
pub const MIGRATION_PHASE_INTERRUPTED: &str = "interrupted";
|
||||||
|
/// The migration finished; the user has not yet confirmed or rolled back.
|
||||||
|
pub const MIGRATION_PHASE_AWAITING: &str = "awaiting-confirmation";
|
||||||
|
|
||||||
|
impl MigrationState {
|
||||||
|
pub fn new(
|
||||||
|
from_image_id: Option<String>,
|
||||||
|
to_base_id: Option<String>,
|
||||||
|
options: MigrationOptions,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
phase: MIGRATION_PHASE_IN_PROGRESS.to_string(),
|
||||||
|
from_image_id,
|
||||||
|
to_base_id,
|
||||||
|
started_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
report: None,
|
||||||
|
rollback_image: None,
|
||||||
|
staging_path: None,
|
||||||
|
options,
|
||||||
|
plan: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
pub mod project;
|
|
||||||
pub mod container_config;
|
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod container_config;
|
||||||
|
pub mod gateway_settings;
|
||||||
|
pub mod migration;
|
||||||
|
pub mod note;
|
||||||
|
pub mod project;
|
||||||
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
|
||||||
pub use container_config::*;
|
|
||||||
pub use app_settings::*;
|
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::*;
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -6,12 +8,339 @@ pub struct EnvVar {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||||
|
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||||
|
///
|
||||||
|
/// ## Why a charset rule, and not just the reserved-name list
|
||||||
|
///
|
||||||
|
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||||
|
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||||
|
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||||
|
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||||
|
/// that is not an identifier travels through unchallenged.
|
||||||
|
///
|
||||||
|
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||||
|
/// exported shell function: bash imports those at startup and the *body* is the
|
||||||
|
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||||
|
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||||
|
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||||
|
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||||
|
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||||
|
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||||
|
/// code execution inside the container at commit time.
|
||||||
|
///
|
||||||
|
/// So the rule is the shape of the thing rather than a list of the names that
|
||||||
|
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||||
|
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||||
|
/// a `%`, a `(` or a space in an environment variable name.
|
||||||
|
///
|
||||||
|
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||||
|
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||||
|
/// break a setting that works.
|
||||||
|
pub fn is_valid_env_key(key: &str) -> bool {
|
||||||
|
let mut chars = key.trim().chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a custom environment variable list that is about to be stored,
|
||||||
|
/// admitting the entries it is already stored with.
|
||||||
|
///
|
||||||
|
/// Same shape, and the same reasoning, as
|
||||||
|
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||||
|
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||||
|
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||||
|
/// a project unsavable *entirely* — `update_project` is the single command
|
||||||
|
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||||
|
/// is already being handed to every container that starts. An entry carried
|
||||||
|
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||||
|
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||||
|
/// key through this command.
|
||||||
|
///
|
||||||
|
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||||
|
/// second copy of an existing entry is a new entry.
|
||||||
|
///
|
||||||
|
/// The blank entry is not a violation. "+ Add variable" appends
|
||||||
|
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||||
|
/// turn the button itself into an error toast; `create_container` skips an
|
||||||
|
/// empty key, so it reaches nothing.
|
||||||
|
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||||
|
// An entry with no key is the placeholder, whatever is in its value:
|
||||||
|
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||||
|
// to refuse. The editor saves on every blur, and typing the value before
|
||||||
|
// the name is an ordinary way to fill a row in.
|
||||||
|
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||||
|
|
||||||
|
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||||
|
*carried
|
||||||
|
.entry((v.key.as_str(), v.value.as_str()))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||||
|
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||||
|
Some(remaining) if *remaining > 0 => {
|
||||||
|
*remaining -= 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !is_valid_env_key(&v.key) {
|
||||||
|
return Err(format!(
|
||||||
|
"'{}' is not a usable environment variable name. Use a letter or \
|
||||||
|
underscore followed by letters, digits or underscores.",
|
||||||
|
v.key
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ProjectPath {
|
pub struct ProjectPath {
|
||||||
pub host_path: String,
|
pub host_path: String,
|
||||||
pub mount_name: String,
|
pub mount_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PortMapping {
|
||||||
|
pub host_port: u16,
|
||||||
|
pub container_port: u16,
|
||||||
|
#[serde(default = "default_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocol() -> String {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_full_permissions() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `use_shared_auth_token` defaults to **on**: once the user has run
|
||||||
|
/// `claude setup-token` once, every existing Anthropic-backend project should
|
||||||
|
/// pick the token up without being edited one by one. Projects deliberately
|
||||||
|
/// pinned to their own `claude login` identity opt out.
|
||||||
|
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
|
||||||
|
/// the single definition of that mapping and must be used by every call site.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum PermissionMode {
|
||||||
|
/// Read-only planning mode.
|
||||||
|
Plan,
|
||||||
|
/// Claude Code's own default behavior (prompts for permission).
|
||||||
|
#[default]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionMode {
|
||||||
|
/// The CLI flags this mode adds to a `claude` invocation.
|
||||||
|
/// Defined once here so every call site stays in sync.
|
||||||
|
pub fn cli_args(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()],
|
||||||
|
PermissionMode::Default => Vec::new(),
|
||||||
|
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()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var.
|
||||||
|
/// Matches the serde `camelCase` representation.
|
||||||
|
pub fn as_env_value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => "plan",
|
||||||
|
PermissionMode::Default => "default",
|
||||||
|
PermissionMode::AcceptEdits => "acceptEdits",
|
||||||
|
PermissionMode::Auto => "auto",
|
||||||
|
PermissionMode::Bypass => "bypass",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(from = "StoredClaudeCodeSettings")]
|
||||||
|
/// Every field is three-state, and the third state is load-bearing.
|
||||||
|
///
|
||||||
|
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||||
|
/// whatever the global settings say"; for the *global* settings it is "leave
|
||||||
|
/// Claude Code's own default alone". `Some(false)` is a deliberate off, which
|
||||||
|
/// is what lets a project turn a globally-enabled setting back off — with a
|
||||||
|
/// plain `bool` there is no value that can express that, which is why these
|
||||||
|
/// were widened from `bool`.
|
||||||
|
pub struct ClaudeCodeSettings {
|
||||||
|
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
|
||||||
|
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||||
|
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||||
|
/// one. All three are distinct — "let it choose" is not "classic".
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tui_mode: Option<String>,
|
||||||
|
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||||
|
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||||
|
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effort: Option<String>,
|
||||||
|
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||||
|
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||||
|
/// zero value of this field has to mean "leave it on".
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auto_scroll_disabled: Option<bool>,
|
||||||
|
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||||
|
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub focus_mode: Option<bool>,
|
||||||
|
/// Show thinking summaries in responses
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub show_thinking_summaries: Option<bool>,
|
||||||
|
/// Turn the session recap **off**.
|
||||||
|
///
|
||||||
|
/// Held in the disabled sense for the same reason as `auto_scroll_disabled`,
|
||||||
|
/// and the rename from the old `enable_session_recap` is load-bearing rather
|
||||||
|
/// than cosmetic. Claude Code's recap is on by default, so the old field was
|
||||||
|
/// inverted: switching it on was a no-op and switching it off did nothing at
|
||||||
|
/// all. Reusing the name with the opposite meaning would have read every
|
||||||
|
/// stored `enable_session_recap: false` — which is what every project that
|
||||||
|
/// never touched the control holds — as "the user turned the recap off" and
|
||||||
|
/// silently disabled it for all of them. A new name lets the old key be
|
||||||
|
/// ignored, which lands every existing project on the correct default.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_recap_disabled: Option<bool>,
|
||||||
|
/// Strip credentials from subprocess environments
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub env_scrub: Option<bool>,
|
||||||
|
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt_caching_1h: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||||
|
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||||
|
/// through.
|
||||||
|
///
|
||||||
|
/// ## The upgrade this exists to survive
|
||||||
|
///
|
||||||
|
/// Before the widening, the five booleans were plain `bool`s with
|
||||||
|
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||||
|
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||||
|
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||||
|
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||||
|
/// information at all: it was the only value an unset switch could produce, and
|
||||||
|
/// the global always won.
|
||||||
|
///
|
||||||
|
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||||
|
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||||
|
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||||
|
/// credentials from subprocess environments") among them. There is no store
|
||||||
|
/// migration anywhere: `projects_store` parses these structs directly.
|
||||||
|
///
|
||||||
|
/// ## How an old record is told apart from a new one
|
||||||
|
///
|
||||||
|
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||||
|
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||||
|
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||||
|
/// so the old key could be ignored (see the doc on that field), and the new
|
||||||
|
/// code has never written it. Its presence is therefore an exact statement that
|
||||||
|
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||||
|
/// booleans are read back that way: `true` is a real choice and survives,
|
||||||
|
/// `false` becomes `None` and inherits again.
|
||||||
|
///
|
||||||
|
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||||
|
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||||
|
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||||
|
/// an older binary reads an absent key as `false` through its own
|
||||||
|
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||||
|
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||||
|
/// and starts empty on an error.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StoredClaudeCodeSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
tui_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
effort: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
auto_scroll_disabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
focus_mode: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
show_thinking_summaries: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
session_recap_disabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
env_scrub: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_caching_1h: Option<bool>,
|
||||||
|
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||||
|
/// use of its value: presence dates the record. Its meaning was inverted
|
||||||
|
/// and it never worked, so it is read for the marker and discarded.
|
||||||
|
#[serde(default)]
|
||||||
|
enable_session_recap: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||||
|
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||||
|
let pre_widening = stored.enable_session_recap.is_some();
|
||||||
|
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||||
|
// so it means "not set at this level" and must inherit. A `true` was a
|
||||||
|
// real choice either way.
|
||||||
|
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||||
|
ClaudeCodeSettings {
|
||||||
|
tui_mode: stored.tui_mode,
|
||||||
|
effort: stored.effort,
|
||||||
|
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||||
|
focus_mode: read(stored.focus_mode),
|
||||||
|
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||||
|
session_recap_disabled: read(stored.session_recap_disabled),
|
||||||
|
env_scrub: read(stored.env_scrub),
|
||||||
|
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -19,18 +348,104 @@ pub struct Project {
|
|||||||
pub paths: Vec<ProjectPath>,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
#[serde(alias = "auth_mode")]
|
||||||
|
pub backend: Backend,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
|
pub ollama_config: Option<OllamaConfig>,
|
||||||
|
#[serde(default, alias = "llama_cpp_config")]
|
||||||
|
pub llamacpp_config: Option<LlamaCppConfig>,
|
||||||
|
#[serde(alias = "litellm_config")]
|
||||||
|
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sandbox_mode_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mission_control_enabled: bool,
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// **On by default**, and opt-*out* rather than opt-in — see
|
||||||
|
/// [`default_auth_bridge_enabled`] for why the default is the feature.
|
||||||
|
#[serde(default = "default_auth_bridge_enabled")]
|
||||||
|
pub auth_bridge_enabled: bool,
|
||||||
|
/// Opt in to the browser-view pane, which watches and takes over the
|
||||||
|
/// browser Claude drives with Playwright inside the container. Purely
|
||||||
|
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||||
|
/// container-recreation label.
|
||||||
|
///
|
||||||
|
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
|
||||||
|
/// rather than keeping its own copy, so the pane comes back the way it was
|
||||||
|
/// left. Off by default, and unlike the auth bridge it stays that way — a
|
||||||
|
/// view costs a container exec, a Node daemon and a host port, and a
|
||||||
|
/// container without Playwright cannot serve one at all.
|
||||||
|
///
|
||||||
|
/// Durable does **not** mean auto-started: nothing brings a viewer up on
|
||||||
|
/// app start, so a project left enabled reports `enabled` with a state of
|
||||||
|
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub browser_view_enabled: bool,
|
||||||
|
/// Grant the container what a VPN client needs to build a tunnel:
|
||||||
|
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
|
||||||
|
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
|
||||||
|
/// OpenVPN) installs and runs but its connection attempt hangs until it
|
||||||
|
/// times out, because it cannot create the tunnel interface or touch the
|
||||||
|
/// routing table.
|
||||||
|
///
|
||||||
|
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
|
||||||
|
/// container reconfigure its own network stack, which reaches further than
|
||||||
|
/// it sounds — see `vpn_host_config` for what it does and does not confer.
|
||||||
|
/// Unlike `auth_bridge_enabled` this *is*
|
||||||
|
/// container state, so it carries a `triple-c.vpn-support` label and is
|
||||||
|
/// compared in `container_needs_recreation` — capabilities and devices are
|
||||||
|
/// fixed at creation and can only change by recreating the container.
|
||||||
|
#[serde(default)]
|
||||||
|
pub vpn_support_enabled: bool,
|
||||||
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
|
/// [`Backend::Anthropic`] and a token has actually been stored.
|
||||||
|
///
|
||||||
|
/// Defaults to **true** so a single `setup-token` run covers every project;
|
||||||
|
/// turn it off to pin a project to the identity it logged in with inside
|
||||||
|
/// its own container.
|
||||||
|
#[serde(default = "default_use_shared_auth_token")]
|
||||||
|
pub use_shared_auth_token: bool,
|
||||||
|
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
||||||
|
/// because it is the value already stored in users' `projects.json`; it is
|
||||||
|
/// the fallback in `effective_permission_mode()` so old projects keep
|
||||||
|
/// behaving identically without a data migration.
|
||||||
|
#[serde(default = "default_full_permissions")]
|
||||||
|
pub full_permissions: bool,
|
||||||
|
/// Per-project permission mode. `None` means "not set yet" → fall back to
|
||||||
|
/// the legacy `full_permissions` flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission_mode: Option<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
/// Per-project override for the corporate CA certificate path (file or
|
||||||
|
/// directory). Blank falls back to `AppSettings::ca_cert_path`.
|
||||||
|
///
|
||||||
|
/// `#[serde(default)]` rather than a required field: every project stored
|
||||||
|
/// before this existed must keep loading.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ca_cert_path: Option<String>,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
pub git_user_email: Option<String>,
|
pub git_user_email: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_env_vars: Vec<EnvVar>,
|
pub custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub port_mappings: Vec<PortMapping>,
|
||||||
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
/// User-defined display names for terminal tabs, keyed by session id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub renamed_session_names: HashMap<String, String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -45,21 +460,109 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// of reporting a clean removal that was not one.
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
///
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// The project record is dropped from `projects.json` regardless — see the
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||||
#[serde(rename_all = "snake_case")]
|
/// anything named here is also written to a pending-cleanup record that
|
||||||
pub enum AuthMode {
|
/// startup housekeeping retries, so it stays reachable after the project it
|
||||||
Login,
|
/// belonged to no longer exists.
|
||||||
ApiKey,
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
Bedrock,
|
pub struct ProjectRemovalReport {
|
||||||
|
/// The project's container, if it could not be removed. Named by its
|
||||||
|
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||||
|
/// not the container id, since the id can be stale or absent and the
|
||||||
|
/// name is what a later retry can still resolve.
|
||||||
|
pub container: Option<String>,
|
||||||
|
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Named volumes (home, claude config) that could not be removed.
|
||||||
|
pub volumes: Vec<String>,
|
||||||
|
/// True once the leftovers above were durably recorded for automatic
|
||||||
|
/// retry on the next launch. False means the pending-cleanup record
|
||||||
|
/// itself could not be written — nothing will retry these, and the UI
|
||||||
|
/// must say so rather than promising a retry that will not happen.
|
||||||
|
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||||
|
pub retry_scheduled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl ProjectRemovalReport {
|
||||||
|
/// True when nothing was left behind.
|
||||||
|
pub fn is_clean(&self) -> bool {
|
||||||
|
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||||
|
/// stands after restarting, and anything Reset could not clear.
|
||||||
|
///
|
||||||
|
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||||
|
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||||
|
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||||
|
/// there is no pending-cleanup record for either: the project id survives
|
||||||
|
/// Reset, so a later Reset attempt can retry them itself.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProjectResetOutcome {
|
||||||
|
pub project: Project,
|
||||||
|
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||||
|
/// more serious of the two leftovers here: the new container is created
|
||||||
|
/// from this image whenever it exists, so a surviving image means Reset
|
||||||
|
/// silently rebuilt the exact system layer it was asked to discard.
|
||||||
|
pub leftover_image: Option<String>,
|
||||||
|
/// Volumes that survived Reset and were mounted into the new container
|
||||||
|
/// unchanged.
|
||||||
|
pub leftover_volumes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which AI model backend/provider the project uses.
|
||||||
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
|
/// - `Ollama`: Local or remote Ollama server
|
||||||
|
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
|
||||||
|
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
|
||||||
|
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Backend {
|
||||||
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
|
Anthropic,
|
||||||
|
Bedrock,
|
||||||
|
Ollama,
|
||||||
|
/// Serialises as `llama_cpp`; the aliases accept the spellings a
|
||||||
|
/// hand-edited `projects.json` is likely to contain.
|
||||||
|
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
|
||||||
|
LlamaCpp,
|
||||||
|
#[serde(alias = "lite_llm", alias = "litellm")]
|
||||||
|
OpenAiCompatible,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Backend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Login
|
Self::Anthropic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// Whether this backend points Claude Code at a non-Anthropic HTTP endpoint
|
||||||
|
/// via `ANTHROPIC_BASE_URL`.
|
||||||
|
///
|
||||||
|
/// Those endpoints serve whatever model *they* were started with, so
|
||||||
|
/// Claude Code's built-in `opus`/`sonnet`/`haiku`/`fable` aliases resolve to
|
||||||
|
/// Anthropic model ids the server has never heard of. Every backend for
|
||||||
|
/// which this returns `true` therefore gets the
|
||||||
|
/// `ANTHROPIC_DEFAULT_*_MODEL` alias vars pinned to the configured model —
|
||||||
|
/// see `docker::container::compute_model_aliases`.
|
||||||
|
///
|
||||||
|
/// Bedrock is deliberately excluded: it talks to AWS, which does host the
|
||||||
|
/// real Anthropic model ids, so Claude Code's own defaults are correct
|
||||||
|
/// there. Anthropic is excluded for the same reason.
|
||||||
|
pub fn uses_custom_endpoint(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Backend::Ollama | Backend::LlamaCpp | Backend::OpenAiCompatible
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +586,78 @@ impl Default for BedrockAuthMethod {
|
|||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
pub auth_method: BedrockAuthMethod,
|
pub auth_method: BedrockAuthMethod,
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_session_token: Option<String>,
|
pub aws_session_token: Option<String>,
|
||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_bearer_token: Option<String>,
|
pub aws_bearer_token: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub disable_prompt_caching: bool,
|
pub disable_prompt_caching: bool,
|
||||||
|
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
|
||||||
|
/// (e.g. "priority"). Empty/None means leave unset.
|
||||||
|
#[serde(default)]
|
||||||
|
pub service_tier: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ollama configuration for a project.
|
||||||
|
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OllamaConfig {
|
||||||
|
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
||||||
|
pub base_url: String,
|
||||||
|
/// Optional model override (e.g., "qwen3.5:27b")
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
/// Optional override for the model the `haiku` alias resolves to.
|
||||||
|
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub haiku_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// llama.cpp (`llama-server`) configuration for a project.
|
||||||
|
///
|
||||||
|
/// `llama-server` natively implements the Anthropic Messages API at
|
||||||
|
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
|
||||||
|
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
|
||||||
|
/// no translation shim.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LlamaCppConfig {
|
||||||
|
/// The base URL of the llama-server instance. `llama-server`'s default
|
||||||
|
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
|
||||||
|
pub base_url: String,
|
||||||
|
/// Optional model override. `llama-server` serves whatever model it was
|
||||||
|
/// started with, so this is mostly the id Claude Code should *say* it is
|
||||||
|
/// using — but it is also what the model aliases are pinned to.
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
/// Optional override for the model the `haiku` alias resolves to.
|
||||||
|
/// Blank falls back to `model_id`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub haiku_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI Compatible endpoint configuration for a project.
|
||||||
|
///
|
||||||
|
/// Despite the name (kept for backward compatibility with existing
|
||||||
|
/// `projects.json` data), the endpoint must implement the **Anthropic Messages
|
||||||
|
/// API** — Claude Code only ever speaks `POST /v1/messages`. Gateways such as
|
||||||
|
/// LiteLLM expose an Anthropic-shaped route and work; a bare
|
||||||
|
/// `/v1/chat/completions` server does not.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAiCompatibleConfig {
|
||||||
|
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||||
|
pub base_url: String,
|
||||||
|
/// API key for the endpoint
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Optional model override
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
/// Optional override for the model the `haiku` alias resolves to.
|
||||||
|
/// Blank falls back to `model_id`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub haiku_model_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
@@ -105,20 +669,46 @@ impl Project {
|
|||||||
paths,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
backend: Backend::default(),
|
||||||
bedrock_config: None,
|
bedrock_config: None,
|
||||||
|
ollama_config: None,
|
||||||
|
llamacpp_config: None,
|
||||||
|
openai_compatible_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: default_auth_bridge_enabled(),
|
||||||
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
|
ca_cert_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
custom_env_vars: Vec::new(),
|
custom_env_vars: Vec::new(),
|
||||||
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
|
claude_code_settings: None,
|
||||||
|
renamed_session_names: HashMap::new(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The permission mode to actually use for this project.
|
||||||
|
/// Falls back to the legacy `full_permissions` boolean when the newer
|
||||||
|
/// `permission_mode` field has never been set.
|
||||||
|
pub fn effective_permission_mode(&self) -> PermissionMode {
|
||||||
|
self.permission_mode.unwrap_or(if self.full_permissions {
|
||||||
|
PermissionMode::Bypass
|
||||||
|
} else {
|
||||||
|
PermissionMode::Default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
@@ -148,3 +738,254 @@ impl Project {
|
|||||||
val
|
val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||||
|
assert!(ProjectRemovalReport::default().is_clean());
|
||||||
|
|
||||||
|
let mut r = ProjectRemovalReport::default();
|
||||||
|
r.container = Some("abc123".to_string());
|
||||||
|
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||||
|
|
||||||
|
let mut r = ProjectRemovalReport::default();
|
||||||
|
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||||
|
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||||
|
|
||||||
|
let mut r = ProjectRemovalReport::default();
|
||||||
|
r.volumes.push("triple-c-home-x".to_string());
|
||||||
|
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom environment variable names ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||||
|
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||||
|
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
// bash's wire format for an exported shell function: the value is
|
||||||
|
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||||
|
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||||
|
"BASH_FUNC_stat%%",
|
||||||
|
"BASH_FUNC_ls()",
|
||||||
|
"MY VAR",
|
||||||
|
"2FAST",
|
||||||
|
"WITH-DASH",
|
||||||
|
"WITH.DOT",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"$(id)",
|
||||||
|
"A=B",
|
||||||
|
] {
|
||||||
|
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(key: &str, value: &str) -> EnvVar {
|
||||||
|
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||||
|
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||||
|
// Introducing it through the Config tab is the escalation.
|
||||||
|
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||||
|
// Already stored: it is handed to every container that starts whether
|
||||||
|
// or not an unrelated save is allowed through, and refusing the save
|
||||||
|
// would make every toggle on the Config tab fail.
|
||||||
|
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||||
|
// Editing its value is a new entry, and refused again.
|
||||||
|
assert!(
|
||||||
|
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||||
|
);
|
||||||
|
// Fixing the name is what the message asks for, and it saves.
|
||||||
|
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||||
|
// Dropping it entirely is always fine.
|
||||||
|
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||||
|
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||||
|
// so this is the button, not an attempt at anything.
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||||
|
// Typing the value before the name is an ordinary way to fill it in,
|
||||||
|
// and an entry with no name reaches no container either way.
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||||
|
let stored = [env("BAD NAME", "v")];
|
||||||
|
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||||
|
// A second copy is a new entry, and held to the rule.
|
||||||
|
assert!(
|
||||||
|
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claude Code settings written before the fields were widened ───────
|
||||||
|
|
||||||
|
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||||
|
/// booleans were plain `bool`s that always serialised, so every project
|
||||||
|
/// that ever opened the editor carries `false` for the ones it never
|
||||||
|
/// touched.
|
||||||
|
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||||
|
"id": "p1",
|
||||||
|
"name": "demo",
|
||||||
|
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||||
|
"container_id": null,
|
||||||
|
"status": "stopped",
|
||||||
|
"backend": "anthropic",
|
||||||
|
"bedrock_config": null,
|
||||||
|
"ollama_config": null,
|
||||||
|
"openai_compatible_config": null,
|
||||||
|
"allow_docker_access": false,
|
||||||
|
"ssh_key_path": null,
|
||||||
|
"git_user_name": null,
|
||||||
|
"git_user_email": null,
|
||||||
|
"claude_code_settings": {
|
||||||
|
"tui_mode": "fullscreen",
|
||||||
|
"effort": null,
|
||||||
|
"auto_scroll_disabled": false,
|
||||||
|
"focus_mode": false,
|
||||||
|
"show_thinking_summaries": false,
|
||||||
|
"enable_session_recap": false,
|
||||||
|
"env_scrub": false,
|
||||||
|
"prompt_caching_1h": false
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||||
|
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||||
|
let stored = project.claude_code_settings.expect("settings should parse");
|
||||||
|
|
||||||
|
// Read verbatim these would be `Some(false)`, which under
|
||||||
|
// `docker::container::merge_claude_code_settings` beats the global.
|
||||||
|
assert_eq!(stored.env_scrub, None);
|
||||||
|
assert_eq!(stored.auto_scroll_disabled, None);
|
||||||
|
assert_eq!(stored.focus_mode, None);
|
||||||
|
assert_eq!(stored.show_thinking_summaries, None);
|
||||||
|
assert_eq!(stored.prompt_caching_1h, None);
|
||||||
|
assert_eq!(stored.session_recap_disabled, None);
|
||||||
|
// A value the user did choose is untouched.
|
||||||
|
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||||
|
|
||||||
|
// The merge rule itself, spelled the way
|
||||||
|
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||||
|
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||||
|
// and it has to go on winning, because the user never turned this off.
|
||||||
|
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||||
|
assert_eq!(
|
||||||
|
stored.env_scrub.or(global.env_scrub),
|
||||||
|
Some(true),
|
||||||
|
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||||
|
// Same record without the pre-widening key: this `false` is the
|
||||||
|
// deliberate off the widening exists to make expressible.
|
||||||
|
let json = r#"{ "env_scrub": false }"#;
|
||||||
|
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(chosen.env_scrub, Some(false));
|
||||||
|
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||||
|
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||||
|
// A downgrade parses these fields as plain `bool` with
|
||||||
|
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||||
|
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||||
|
// with one null empties the whole list and the next save persists that.
|
||||||
|
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||||
|
assert_eq!(json, "{}");
|
||||||
|
assert!(!json.contains("null"));
|
||||||
|
|
||||||
|
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||||
|
let json = serde_json::to_string(&partial).unwrap();
|
||||||
|
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||||
|
// And it reads back as what it is.
|
||||||
|
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(round_tripped, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The host-side per-project toggles ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
|
||||||
|
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
|
||||||
|
// record written by a shipped binary and has no `auth_bridge_enabled`
|
||||||
|
// key at all. Without this, every existing project keeps hanging on
|
||||||
|
// `claude login` until its owner finds the toggle.
|
||||||
|
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
|
||||||
|
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||||
|
assert!(project.auth_bridge_enabled);
|
||||||
|
|
||||||
|
// The browser view is the other way round and must stay so: it costs a
|
||||||
|
// Node daemon, a container exec loop and a host port, and most
|
||||||
|
// containers have no Playwright to serve it with.
|
||||||
|
assert!(!project.browser_view_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turning_the_auth_bridge_off_survives_the_default() {
|
||||||
|
// Opt-out has to be expressible, or the toggle does nothing across a
|
||||||
|
// restart. An explicit `false` in the file beats the default.
|
||||||
|
let json = r#"{ "auth_bridge_enabled": false }"#;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JustTheFlag {
|
||||||
|
#[serde(default = "default_auth_bridge_enabled")]
|
||||||
|
auth_bridge_enabled: bool,
|
||||||
|
}
|
||||||
|
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!parsed.auth_bridge_enabled);
|
||||||
|
|
||||||
|
// And a saved project always writes the key, so the choice is pinned
|
||||||
|
// rather than re-defaulted on the next load.
|
||||||
|
let mut p = Project::new("demo".to_string(), Vec::new());
|
||||||
|
p.auth_bridge_enabled = false;
|
||||||
|
let round_tripped: Project =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||||
|
assert!(!round_tripped.auth_bridge_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
|
||||||
|
let p = Project::new("demo".to_string(), Vec::new());
|
||||||
|
assert!(p.auth_bridge_enabled);
|
||||||
|
assert!(!p.browser_view_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
|
||||||
|
// `ProjectsStore::new` runs every record through this before
|
||||||
|
// deserialising. If it inserted either key — even as `false` — the
|
||||||
|
// serde default above would never be consulted for an existing project
|
||||||
|
// and this change would be a no-op on exactly the projects it is for.
|
||||||
|
let legacy = serde_json::json!({
|
||||||
|
"id": "p1",
|
||||||
|
"name": "demo",
|
||||||
|
"path": "/home/u/demo",
|
||||||
|
});
|
||||||
|
let migrated = Project::migrate_from_value(legacy);
|
||||||
|
let obj = migrated.as_object().unwrap();
|
||||||
|
assert!(obj.contains_key("paths"), "the migration should still do its own job");
|
||||||
|
assert!(!obj.contains_key("auth_bridge_enabled"));
|
||||||
|
assert!(!obj.contains_key("browser_view_enabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
||||||
|
//! and after decryption (see `storage::settings_crypto`). It bundles
|
||||||
|
//! `AppSettings` — with one field carved out, see below — with the global
|
||||||
|
//! secrets that live in the OS keychain instead: the shared Claude Code
|
||||||
|
//! OAuth login and the model gateway's two keys. Per-project settings,
|
||||||
|
//! per-project secrets, and anything living in a project's Docker volumes
|
||||||
|
//! are deliberately out of scope: this exports the *host* environment, not
|
||||||
|
//! any one project's.
|
||||||
|
//!
|
||||||
|
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
||||||
|
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
||||||
|
//! server that binds every interface, stored as a plain field on the
|
||||||
|
//! struct that is otherwise safe to treat as config. A review of this
|
||||||
|
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
||||||
|
//! that token along as if it were as inert as a port number, and — worse —
|
||||||
|
//! importing it would apply `web_terminal.enabled` and the token together
|
||||||
|
//! with no more warning than any other setting, letting a crafted export
|
||||||
|
//! silently stand up a LAN-listening terminal server with an
|
||||||
|
//! attacker-known token on the next launch. `export_settings` /
|
||||||
|
//! `apply_settings_import` blank this field out of the `settings` they
|
||||||
|
//! read from and write to, and it travels only through
|
||||||
|
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
||||||
|
//! "only overwrite what the import actually has" treatment as the other
|
||||||
|
//! three secrets.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{AppSettings, ImageSource};
|
||||||
|
|
||||||
|
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
||||||
|
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
||||||
|
/// field is ever removed or its meaning changes. `apply_settings_import`
|
||||||
|
/// checks this before touching anything.
|
||||||
|
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The global secrets bundled into an export. Deliberately a separate struct
|
||||||
|
/// from `AppSettings`: these live in the OS keychain, never in
|
||||||
|
/// `settings.json`, and — outside of this export/import flow — the values
|
||||||
|
/// themselves never cross into the frontend; see the doc comments on
|
||||||
|
/// `storage::secure::get_gateway_api_key` and
|
||||||
|
/// `commands::settings_export_commands` for why that boundary matters here
|
||||||
|
/// too.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ExportedSecrets {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_oauth_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_master_key: Option<String>,
|
||||||
|
/// See the module doc comment — this is `AppSettings::web_terminal
|
||||||
|
/// .access_token`, carved out because it is a live bearer credential,
|
||||||
|
/// not config, despite living on a struct that is otherwise safe to
|
||||||
|
/// export wholesale.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_terminal_access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportedSecrets {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
||||||
|
blank(&self.claude_oauth_token)
|
||||||
|
&& blank(&self.gateway_api_key)
|
||||||
|
&& blank(&self.gateway_master_key)
|
||||||
|
&& blank(&self.web_terminal_access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `apply_settings_import` hands back: the settings that were actually
|
||||||
|
/// saved, plus a human-readable note for each keychain secret this import
|
||||||
|
/// carried but could not be restored. A keychain write failing partway
|
||||||
|
/// through must not read as unqualified success just because the settings
|
||||||
|
/// half of the import went through.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsImportOutcome {
|
||||||
|
pub settings: AppSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret_restore_warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full plaintext payload — this is what gets encrypted on export and
|
||||||
|
/// what decryption recovers on import. Never written to disk unencrypted;
|
||||||
|
/// see `storage::settings_crypto`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsExportPayload {
|
||||||
|
pub format_version: u32,
|
||||||
|
/// RFC3339. Purely informational — shown in the import preview so a user
|
||||||
|
/// picking between a few old export files has something to go on.
|
||||||
|
pub exported_at: String,
|
||||||
|
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
||||||
|
/// field below already round-trips through `#[serde(default)]`-covered
|
||||||
|
/// `AppSettings`, so an older or newer export still deserializes; this is
|
||||||
|
/// for a human to notice "this is from a much older version" if an import
|
||||||
|
/// ever looks wrong, not something the code branches on.
|
||||||
|
pub app_version: String,
|
||||||
|
pub settings: AppSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: ExportedSecrets,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `preview_settings_import` hands the frontend before anything is
|
||||||
|
/// applied — counts and presence flags only, **never** a secret value itself,
|
||||||
|
/// so this type is safe to return across the IPC boundary and render
|
||||||
|
/// directly. The confirmation UI is built from this.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsImportPreview {
|
||||||
|
pub exported_at: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub custom_env_var_count: usize,
|
||||||
|
pub gateway_model_count: usize,
|
||||||
|
pub has_claude_code_settings: bool,
|
||||||
|
pub has_claude_oauth_token: bool,
|
||||||
|
pub has_gateway_api_key: bool,
|
||||||
|
pub has_gateway_master_key: bool,
|
||||||
|
pub has_web_terminal_access_token: bool,
|
||||||
|
/// Whether the imported settings turn the web terminal on. Named
|
||||||
|
/// separately from the token above: `enabled` and the token are two
|
||||||
|
/// different fields, either can be true without the other, and
|
||||||
|
/// "this import turns on a service that listens on your network" is
|
||||||
|
/// exactly the kind of change a wholesale settings replace must not
|
||||||
|
/// bury in a generic "settings replaced" line — see the module doc
|
||||||
|
/// comment on why this field exists at all.
|
||||||
|
pub enables_web_terminal: bool,
|
||||||
|
/// Non-blank custom base URLs the import would set, so a redirect of
|
||||||
|
/// model traffic to somewhere other than the usual provider is visible
|
||||||
|
/// at import time rather than discovered later. These are endpoints, not
|
||||||
|
/// secrets — safe to show verbatim, unlike everything above.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ollama_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub llamacpp_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_compatible_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_api_base: Option<String>,
|
||||||
|
/// Whether the import sets a custom Docker image, and its name if so —
|
||||||
|
/// disclosed for the same reason as the base URLs above, and arguably
|
||||||
|
/// more sharply: this is the image *every* project container is created
|
||||||
|
/// from (`models::container_config::resolve_image_name`), so a crafted
|
||||||
|
/// export pointing it at an attacker-controlled image is a path to
|
||||||
|
/// running arbitrary code with whatever a project's containers are
|
||||||
|
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
||||||
|
/// not merely a redirected API endpoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_source: ImageSource,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_image_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||||
|
/// into a preview a user reads and a UI renders without truncation of its
|
||||||
|
/// own. Applied to every field above that carries free-form text straight
|
||||||
|
/// from the import file rather than a count or a boolean — a base URL or an
|
||||||
|
/// image name a hostile export author controls has had no validation done
|
||||||
|
/// on it yet at preview time, and nothing stops it from being pathological
|
||||||
|
/// (embedded control characters, or long enough to blow out the confirmation
|
||||||
|
/// dialog and push the security warnings below it off screen).
|
||||||
|
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
||||||
|
|
||||||
|
fn sanitize_for_preview(value: &str) -> String {
|
||||||
|
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
||||||
|
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
||||||
|
format!("{}…", truncated)
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsImportPreview {
|
||||||
|
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||||
|
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||||
|
let sanitized_non_blank = |s: &Option<String>| {
|
||||||
|
s.as_deref()
|
||||||
|
.map(sanitize_for_preview)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
exported_at: payload.exported_at.clone(),
|
||||||
|
app_version: payload.app_version.clone(),
|
||||||
|
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
||||||
|
gateway_model_count: payload.settings.gateway.models.len(),
|
||||||
|
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
||||||
|
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
||||||
|
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
||||||
|
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
||||||
|
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
||||||
|
enables_web_terminal: payload.settings.web_terminal.enabled,
|
||||||
|
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
||||||
|
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
||||||
|
openai_compatible_base_url: sanitized_non_blank(
|
||||||
|
&payload.settings.global_openai_compatible.base_url,
|
||||||
|
),
|
||||||
|
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
||||||
|
image_source: payload.settings.image_source.clone(),
|
||||||
|
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::AppSettings;
|
||||||
|
|
||||||
|
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
||||||
|
let settings = AppSettings {
|
||||||
|
global_custom_env_vars: vec![
|
||||||
|
crate::models::EnvVar {
|
||||||
|
key: "A".to_string(),
|
||||||
|
value: "1".to_string(),
|
||||||
|
},
|
||||||
|
crate::models::EnvVar {
|
||||||
|
key: "B".to_string(),
|
||||||
|
value: "2".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||||
|
app_version: "0.4.14".to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_preview_never_carries_a_secret_value() {
|
||||||
|
let payload = payload_with(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
||||||
|
gateway_api_key: Some("sk-another-secret".to_string()),
|
||||||
|
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
||||||
|
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
||||||
|
});
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
let serialized = serde_json::to_string(&preview).unwrap();
|
||||||
|
|
||||||
|
assert!(!serialized.contains("sk-super-secret-token"));
|
||||||
|
assert!(!serialized.contains("sk-another-secret"));
|
||||||
|
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
||||||
|
assert!(!serialized.contains("wt-super-secret-token"));
|
||||||
|
assert!(preview.has_claude_oauth_token);
|
||||||
|
assert!(preview.has_gateway_api_key);
|
||||||
|
assert!(preview.has_gateway_master_key);
|
||||||
|
assert!(preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
||||||
|
// A keychain entry that exists but holds only whitespace must not
|
||||||
|
// read as "present" — same "blank counts as absent" rule the
|
||||||
|
// keychain layer itself applies when storing these.
|
||||||
|
let payload = payload_with(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some(" ".to_string()),
|
||||||
|
gateway_api_key: None,
|
||||||
|
gateway_master_key: None,
|
||||||
|
web_terminal_access_token: Some(" ".to_string()),
|
||||||
|
});
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert!(!preview.has_claude_oauth_token);
|
||||||
|
assert!(!preview.has_gateway_api_key);
|
||||||
|
assert!(!preview.has_gateway_master_key);
|
||||||
|
assert!(!preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
||||||
|
// `enabled` and the token are independent fields — a crafted export
|
||||||
|
// could set one without the other, and both are worth a user's
|
||||||
|
// attention: this is the field that exists specifically so "this
|
||||||
|
// import turns on a service that listens on your network" cannot
|
||||||
|
// hide inside a generic "settings replaced" summary.
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.web_terminal.enabled = true;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert!(preview.enables_web_terminal);
|
||||||
|
assert!(!preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
||||||
|
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
||||||
|
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(
|
||||||
|
preview.ollama_base_url.as_deref(),
|
||||||
|
Some("http://attacker.example:11434")
|
||||||
|
);
|
||||||
|
assert_eq!(preview.llamacpp_base_url, None);
|
||||||
|
assert_eq!(preview.openai_compatible_base_url, None);
|
||||||
|
assert_eq!(
|
||||||
|
preview.gateway_api_base.as_deref(),
|
||||||
|
Some("https://gateway.example/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counts_reflect_the_real_settings() {
|
||||||
|
let payload = payload_with(ExportedSecrets::default());
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(preview.custom_env_var_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
||||||
|
assert!(ExportedSecrets::default().is_empty());
|
||||||
|
assert!(!ExportedSecrets {
|
||||||
|
claude_oauth_token: Some("x".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
||||||
|
// Matches the "blank counts as absent" rule every other consumer of
|
||||||
|
// these fields applies (`has_claude_oauth_token` and friends above) —
|
||||||
|
// a keychain entry that exists but holds only whitespace carries
|
||||||
|
// nothing usable, so the export-time "nothing to export" log line
|
||||||
|
// must still fire for it.
|
||||||
|
assert!(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some(" ".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_docker_image_is_surfaced() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.image_source = crate::models::ImageSource::Custom;
|
||||||
|
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
||||||
|
assert_eq!(
|
||||||
|
preview.custom_image_name.as_deref(),
|
||||||
|
Some("ghcr.io/attacker/triple-c:latest")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.global_ollama.base_url =
|
||||||
|
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
let shown = preview.ollama_base_url.expect("non-blank base url");
|
||||||
|
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
||||||
|
// +1 for the trailing ellipsis appended when truncated.
|
||||||
|
assert!(
|
||||||
|
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
||||||
|
"preview string was not capped: {} chars",
|
||||||
|
shown.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||