Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
29fd7de909 | ||
|
|
98a6c8fd56 | ||
|
|
c71e54a35f | ||
|
|
b41077e799 | ||
|
|
704d3b8f79 | ||
|
|
2de00b3c55 | ||
|
|
eb1324cb16 | ||
|
|
d42b741337 | ||
|
|
cc5f691677 |
@@ -1,10 +1,67 @@
|
|||||||
name: Build App (Preview)
|
name: Build App (Preview)
|
||||||
|
|
||||||
# Builds the Tauri app for branches other than main and exposes the bundles as
|
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||||
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||||
# smoke-testing of feature branches before they merge.
|
# sync.
|
||||||
|
#
|
||||||
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||||
|
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||||
|
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||||
|
# unreachable set of bundles; it is now releases-only.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
# How many preview releases survive a run, newest first — including the one
|
||||||
|
# just published.
|
||||||
|
KEEP_PREVIEWS: "2"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# Every push to an open PR: this *is* the branch's build check — it compiles
|
||||||
|
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
||||||
|
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
|
- ".gitea/workflows/build-app-preview.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,6 +69,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
sha: ${{ steps.version.outputs.SHA }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -23,13 +81,88 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
# From the checkout, not from `gitea.sha`: on a pull_request event
|
||||||
|
# that variable can be the merge ref, which is not the commit anyone
|
||||||
|
# is testing and not something to hang a tag on.
|
||||||
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The patch number is computed exactly as build-app.yml does it, so a
|
||||||
|
# preview is labelled with the version the release it previews would
|
||||||
|
# carry. This used to be hard-coded `.0`, which made every preview
|
||||||
|
# installer claim to be x.y.0 no matter what it contained.
|
||||||
|
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||||
|
if [ -n "$LATEST_TAG" ]; then
|
||||||
|
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||||
|
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||||
|
else
|
||||||
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
|
PATCH=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Computed preview version: ${VERSION}"
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
build-linux:
|
# 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
|
runs-on: ubuntu-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version]
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
||||||
|
tag: ${{ steps.release.outputs.TAG }}
|
||||||
|
steps:
|
||||||
|
- name: Create the preview release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
|
SHA: ${{ needs.compute-version.outputs.sha }}
|
||||||
|
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="preview-${VERSION##*.}"
|
||||||
|
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Idempotent: re-dispatching the same commit must update the existing
|
||||||
|
# release rather than fail on the duplicate tag.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200) echo "Release ${TAG} already exists, reusing" ;;
|
||||||
|
404)
|
||||||
|
echo "Creating release ${TAG}"
|
||||||
|
# prerelease: true keeps it off "latest" — this is a branch build,
|
||||||
|
# not something anyone should install by accident.
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "Failed to parse release id; response was:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -128,17 +261,47 @@ jobs:
|
|||||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
uses: actions/upload-artifact@v4
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
with:
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
path: artifacts/
|
- name: Upload Linux bundles to the preview release
|
||||||
if-no-files-found: error
|
shell: bash
|
||||||
retention-days: 14
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No Linux bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -209,17 +372,47 @@ jobs:
|
|||||||
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
uses: actions/upload-artifact@v4
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
with:
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
path: artifacts/
|
- name: Upload macOS bundles to the preview release
|
||||||
if-no-files-found: error
|
shell: bash
|
||||||
retention-days: 14
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No macOS bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -308,10 +501,78 @@ jobs:
|
|||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
# PowerShell, because this job's default shell is cmd. Same
|
||||||
uses: actions/upload-artifact@v4
|
# delete-then-upload shape as the other two.
|
||||||
with:
|
- name: Upload Windows bundles to the preview release
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
shell: powershell
|
||||||
path: artifacts/
|
env:
|
||||||
if-no-files-found: error
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
retention-days: 14
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
|
$files = @(Get-ChildItem -File -Path artifacts\*)
|
||||||
|
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
||||||
|
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Replacing existing asset $name"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the preview list short. Runs after the builds and only if all three
|
||||||
|
# succeeded: a half-published run must not be what evicts a good older build.
|
||||||
|
prune-previews:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [create-release, build-linux, build-macos, build-windows]
|
||||||
|
steps:
|
||||||
|
- name: Delete all but the newest preview releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
||||||
|
|
||||||
|
# Newest first by creation time, `preview-` only, and never the one
|
||||||
|
# this run just published — a clock skew must not delete it.
|
||||||
|
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
||||||
|
previews = [r for r in json.load(open("releases.json"))
|
||||||
|
if r["tag_name"].startswith("preview-")]
|
||||||
|
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
|
for r in previews[keep:]:
|
||||||
|
if r["tag_name"] != keep_tag:
|
||||||
|
print(r["id"], r["tag_name"])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "${DOOMED}" ]; then
|
||||||
|
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${DOOMED}" | while read -r ID TAG; do
|
||||||
|
[ -z "${ID}" ] && continue
|
||||||
|
echo "Deleting ${TAG} (id ${ID})"
|
||||||
|
# Best effort: a preview someone deleted by hand mid-run is not a
|
||||||
|
# reason to fail a build that otherwise succeeded.
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
||||||
|
done
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ on:
|
|||||||
- "app/**"
|
- "app/**"
|
||||||
- "VERSION"
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "app/**"
|
|
||||||
- "VERSION"
|
|
||||||
- ".gitea/workflows/build-app.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||||
|
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||||
|
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||||
|
# the same three and publishes them. Six OS builds per push, one set of which
|
||||||
|
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
@@ -39,16 +39,55 @@ jobs:
|
|||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||||
|
|
||||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
# The patch number is **one past the highest patch already used**, and
|
||||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
# never a distance.
|
||||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
#
|
||||||
|
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
||||||
|
# not a counter at all: it measures how far HEAD has drifted from
|
||||||
|
# whichever tag sorts highest, and that resets to zero every time a
|
||||||
|
# tag is cut. The published history is the proof — each of these is
|
||||||
|
# exactly what the old formula returned at the time:
|
||||||
|
#
|
||||||
|
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
||||||
|
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
||||||
|
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
||||||
|
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
||||||
|
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
||||||
|
#
|
||||||
|
# Reusing a version is worse than failing to publish one: the macOS
|
||||||
|
# and Windows steps replace assets in place, so a duplicate silently
|
||||||
|
# rewrote a release that had been public for three days. Monotonic
|
||||||
|
# numbering is what stops that at the source.
|
||||||
|
#
|
||||||
|
# Suffixed tags count too. `create-tag` is skipped when any platform
|
||||||
|
# job fails, so a run can publish v0.4.7-mac and never create the
|
||||||
|
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
||||||
|
# same number out twice.
|
||||||
|
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
if [ -n "$LATEST_TAG" ]; then
|
# A re-run of a commit that already released must not mint a new
|
||||||
echo "Latest matching tag: ${LATEST_TAG}"
|
# version just because its own tag now exists.
|
||||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
EXISTING=$(git tag --points-at HEAD \
|
||||||
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||||
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||||
|
| sort -n | tail -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
|
||||||
|
PATCH="${EXISTING}"
|
||||||
|
elif [ -n "$HIGHEST" ]; then
|
||||||
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||||
|
PATCH=$((HIGHEST + 1))
|
||||||
else
|
else
|
||||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||||
PATCH=$(git rev-list --count HEAD)
|
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||||
|
# old fallback here counted every commit in the repository, which
|
||||||
|
# would have made the first 0.4 build 0.4.234.
|
||||||
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
|
PATCH=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||||
@@ -161,21 +200,70 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||||
# Create release
|
|
||||||
curl -s -X POST \
|
# Idempotent get-or-create, matching build-macos. This step used to
|
||||||
|
# POST /releases unconditionally: against a tag that already existed
|
||||||
|
# Gitea answered 409, the grep below found no id, and the run died
|
||||||
|
# with a bare "exitcode '1'" and not one line of output explaining
|
||||||
|
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
||||||
|
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
||||||
|
# pipefail so a failure cannot be stepped over.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
case "${HTTP_CODE}" in
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
200)
|
||||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
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}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
# Upload each artifact
|
|
||||||
|
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
||||||
|
# Versions are monotonic now (see compute-version), so this can only
|
||||||
|
# ever be replacing an asset from a failed run of this same commit —
|
||||||
|
# never one belonging to an already-published version.
|
||||||
for file in artifacts/*; do
|
for file in artifacts/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST \
|
curl -fsS --http1.1 \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||||
|
--max-time 600 \
|
||||||
|
-X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file}" \
|
--data-binary "@${file}" \
|
||||||
@@ -395,6 +483,39 @@ jobs:
|
|||||||
)
|
)
|
||||||
endlocal
|
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 && (
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
- **`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
|
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.
|
`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`)
|
- **`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
|
- **`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
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
@@ -84,8 +95,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
Use `--text-disabled` rather than `disabled:opacity-50`.
|
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||||
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
- **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.
|
- **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.
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
||||||
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
|
`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/`)
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
@@ -97,12 +110,86 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
|
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
|
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.
|
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:
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
- `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.
|
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||||
- `image.rs` — Image build/pull with progress streaming
|
- `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
|
- `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
|
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||||
users have migrated.
|
users have migrated.
|
||||||
@@ -110,15 +197,117 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
- `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
|
- `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!()`
|
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
|
||||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
|
- **`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`
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
### Container (`container/`)
|
### Container (`container/`)
|
||||||
|
|
||||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
|
- **`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)
|
||||||
|
- **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`
|
- **`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
|
- **`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.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
@@ -129,13 +318,72 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
|
|||||||
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||||
preserving credentials.
|
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".**
|
||||||
|
- **`: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
|
### Authentication
|
||||||
|
|
||||||
Per-project, independently configured:
|
Per-project, independently configured:
|
||||||
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||||
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
- **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`)
|
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||||
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
- **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
|
## Styling
|
||||||
|
|
||||||
@@ -157,6 +405,16 @@ Per-project, independently configured:
|
|||||||
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
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
|
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`.
|
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.**
|
- **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
|
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||||
`models/project.rs` for anything that should default to true.
|
`models/project.rs` for anything that should default to true.
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
- [Permission Modes](#permission-modes)
|
- [Permission Modes](#permission-modes)
|
||||||
- [Project Configuration](#project-configuration)
|
- [Project Configuration](#project-configuration)
|
||||||
- [Shared Claude Authentication](#shared-claude-authentication)
|
- [Shared Claude Authentication](#shared-claude-authentication)
|
||||||
|
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay)
|
||||||
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
|
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
|
||||||
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
||||||
- [Ollama Configuration](#ollama-configuration)
|
- [Ollama Configuration](#ollama-configuration)
|
||||||
|
- [llama.cpp Configuration](#llamacpp-configuration)
|
||||||
- [OpenAI Compatible Configuration](#openai-compatible-configuration)
|
- [OpenAI Compatible Configuration](#openai-compatible-configuration)
|
||||||
|
- [Model Aliases and Background Calls](#model-aliases-and-background-calls)
|
||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
- [Web Terminal (Remote Access)](#web-terminal-remote-access)
|
- [Web Terminal (Remote Access)](#web-terminal-remote-access)
|
||||||
- [Terminal Features](#terminal-features)
|
- [Terminal Features](#terminal-features)
|
||||||
@@ -58,8 +61,9 @@ You need access to Claude Code through one of:
|
|||||||
|
|
||||||
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||||
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||||
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
|
- **Ollama** — A local or remote Ollama server (best-effort support)
|
||||||
- **OpenAI Compatible** — Any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) (best-effort support)
|
- **llama.cpp** — A local or remote `llama-server` (best-effort support)
|
||||||
|
- **OpenAI Compatible** — A gateway that implements the **Anthropic Messages API**, such as LiteLLM (best-effort support). A server that only speaks OpenAI's `/v1/chat/completions` will not work — see [OpenAI Compatible Configuration](#openai-compatible-configuration).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -142,11 +146,18 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
|
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
|
||||||
5. Start the container again.
|
5. Start the container again.
|
||||||
|
|
||||||
|
**llama.cpp:**
|
||||||
|
|
||||||
|
1. Stop the container first (most settings can only be changed while stopped).
|
||||||
|
2. Open the project's **Config** tab and, under **Model**, set **Backend** to **llama.cpp**.
|
||||||
|
3. Set the base URL of your `llama-server` (defaults to `http://host.docker.internal:8080`, `llama-server`'s default port). Set the **Model** to the model it is serving.
|
||||||
|
4. Start the container again.
|
||||||
|
|
||||||
**OpenAI Compatible:**
|
**OpenAI Compatible:**
|
||||||
|
|
||||||
1. Stop the container first (most settings can only be changed while stopped).
|
1. Stop the container first (most settings can only be changed while stopped).
|
||||||
2. Open the project's **Config** tab and, under **Model**, set **Backend** to **OpenAI Compatible**.
|
2. Open the project's **Config** tab and, under **Model**, set **Backend** to **OpenAI Compatible**.
|
||||||
3. Set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model.
|
3. Set the base URL of your gateway (defaults to `http://host.docker.internal:4000`, LiteLLM's default port). Optionally set an API key and model.
|
||||||
4. Start the container again.
|
4. Start the container again.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -180,6 +191,12 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
|
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
|
||||||
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
||||||
a terminal.
|
a terminal.
|
||||||
|
|
||||||
|
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
|
||||||
|
Dropping does not change which tab you are looking at — so you can rearrange the strip without
|
||||||
|
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
|
||||||
|
*active* tab the same way without the mouse (they leave text fields alone, where that chord
|
||||||
|
still selects by word). The order is per-session: it is not saved when you quit.
|
||||||
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
||||||
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
||||||
the built-in help.
|
the built-in help.
|
||||||
@@ -200,7 +217,7 @@ for selecting a project and for two quick controls that appear on hover — star
|
|||||||
Claude terminal. Everything else about a project lives in Project Home.
|
Claude terminal. Everything else about a project lives in Project Home.
|
||||||
|
|
||||||
The header shows the project name, its status, how long the container has been up, and the action
|
The header shows the project name, its status, how long the container has been up, and the action
|
||||||
buttons. Below that are five tabs:
|
buttons. Below that are six tabs:
|
||||||
|
|
||||||
| Tab | What it's for |
|
| Tab | What it's for |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -209,6 +226,7 @@ buttons. Below that are five tabs:
|
|||||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||||
| **Files** | Browse, download and upload files inside the container |
|
| **Files** | Browse, download and upload files inside the container |
|
||||||
|
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
@@ -246,6 +264,61 @@ included, and each tile opens a list of what it found.
|
|||||||
|
|
||||||
The counts are only available while the container is running.
|
The counts are only available while the container is running.
|
||||||
|
|
||||||
|
### The Browser Tab
|
||||||
|
|
||||||
|
When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you
|
||||||
|
that browser live — and lets you take it over with your own mouse and keyboard.
|
||||||
|
|
||||||
|
It is **off by default and opted into per project**, and it never installs anything on its own.
|
||||||
|
Opening the tab only *probes* the container, so it can tell you what is missing before you ask for
|
||||||
|
a view; installing Playwright and downloading a browser are separate, labelled buttons that state
|
||||||
|
what they cost before you press them. See
|
||||||
|
[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not
|
||||||
|
pre-installed.
|
||||||
|
|
||||||
|
Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the
|
||||||
|
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||||
|
exposed off the machine.
|
||||||
|
|
||||||
|
#### Opening a page yourself
|
||||||
|
|
||||||
|
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
|
||||||
|
publishes it to this pane. Two uses:
|
||||||
|
|
||||||
|
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
|
||||||
|
a container-side browser completes the login without anything crossing to your host browser.
|
||||||
|
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
|
||||||
|
offers **In container**, which does the same thing in one click.
|
||||||
|
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
|
||||||
|
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
|
||||||
|
around it yourself.
|
||||||
|
|
||||||
|
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
|
||||||
|
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
|
||||||
|
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
|
||||||
|
|
||||||
|
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
|
||||||
|
be *watched* but not resized — a published browser admits only the client that launched it. Set
|
||||||
|
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
|
||||||
|
instead.
|
||||||
|
|
||||||
|
#### Watching it while you work
|
||||||
|
|
||||||
|
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
||||||
|
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||||
|
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
|
||||||
|
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
|
||||||
|
for the reason above. This is a window change only: the browser and the view keep running throughout, so
|
||||||
|
popping out and back costs nothing and interrupts nothing.
|
||||||
|
|
||||||
|
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||||
|
two viewers would both be able to *drive* the browser, and two cursors on one page is not useful.
|
||||||
|
**Put back in tab**, or just closing the window, brings it back.
|
||||||
|
|
||||||
|
The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and
|
||||||
|
stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes
|
||||||
|
it, because a window showing a viewer that no longer exists is worse than no window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Management
|
## Project Management
|
||||||
@@ -398,6 +471,37 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
|||||||
|
|
||||||
> Toggling this requires stopping and restarting the container to take effect.
|
> Toggling this requires stopping and restarting the container to take effect.
|
||||||
|
|
||||||
|
### VPN Support
|
||||||
|
|
||||||
|
When enabled, the container is given the three things a VPN client needs to build a tunnel:
|
||||||
|
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
|
||||||
|
sysctl that WireGuard requires. This is **off by default**.
|
||||||
|
|
||||||
|
Without it, a client such as PIA, WireGuard or OpenVPN installs and its daemon starts normally, but
|
||||||
|
the connection attempt **hangs until it times out** — a default container has no tun device to open
|
||||||
|
and no permission to add an interface or a route, and most clients report that as a generic timeout
|
||||||
|
rather than a permissions error.
|
||||||
|
|
||||||
|
Things worth knowing:
|
||||||
|
|
||||||
|
- Tailscale is the exception: in its `--tun=userspace-networking` mode it needs neither the
|
||||||
|
capability nor the device, so leave this off if that is all you want.
|
||||||
|
|
||||||
|
- `NET_ADMIN` applies to the container's **own** network namespace — it cannot touch the host's
|
||||||
|
interfaces. It is not nothing, though: within that namespace anything in the container can set
|
||||||
|
promiscuous mode and add arbitrary addresses, routes and firewall rules on the Docker bridge it
|
||||||
|
shares with your other containers, and it can flush firewall rules that sandbox mode relies on.
|
||||||
|
Grant it per project, to projects that need it.
|
||||||
|
- The **Docker host's** kernel must have the `tun` module available. With Docker Desktop that is
|
||||||
|
the Linux VM, not your own machine. If it is missing, the container is created but fails to
|
||||||
|
**start**, with an error naming `/dev/net/tun` and pointing back at this setting.
|
||||||
|
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the
|
||||||
|
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
|
||||||
|
|
||||||
|
> This setting can only be changed when the container is stopped. Capabilities and devices are
|
||||||
|
> fixed when a container is created, so toggling it recreates the container on the next start.
|
||||||
|
> Recreation preserves the home and `.claude` volumes — it is not a Reset.
|
||||||
|
|
||||||
### Mission Control
|
### Mission Control
|
||||||
|
|
||||||
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
||||||
@@ -498,6 +602,10 @@ This lives in the sidebar under **Settings → Claude Authentication**.
|
|||||||
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
||||||
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
||||||
|
|
||||||
|
The code is long and easy to truncate. If Anthropic refuses it, the dialog says so and lets you
|
||||||
|
paste another one without restarting the sign-in — the CLI is still waiting. After a few refusals
|
||||||
|
the flow gives up and reports it rather than sitting there.
|
||||||
|
|
||||||
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
||||||
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||||
and nothing is stored.
|
and nothing is stored.
|
||||||
@@ -505,7 +613,7 @@ and nothing is stored.
|
|||||||
### How the token is used
|
### How the token is used
|
||||||
|
|
||||||
- It is injected only into projects whose backend is **Anthropic** — it means nothing to Bedrock,
|
- It is injected only into projects whose backend is **Anthropic** — it means nothing to Bedrock,
|
||||||
Ollama or an OpenAI-compatible endpoint.
|
Ollama, llama.cpp or an OpenAI-compatible gateway.
|
||||||
- Each project can opt out under **Config → Model** ("Use the shared Claude token"). Projects are
|
- Each project can opt out under **Config → Model** ("Use the shared Claude token"). Projects are
|
||||||
opted **in** by default, so a single sign-in covers your whole fleet; opt a project out if you
|
opted **in** by default, so a single sign-in covers your whole fleet; opt a project out if you
|
||||||
want it pinned to its own `claude login` identity.
|
want it pinned to its own `claude login` identity.
|
||||||
@@ -527,6 +635,95 @@ is next started, at which point the same recreation clears the variable.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Opening URLs in Your Browser (URL Relay)
|
||||||
|
|
||||||
|
There is no browser inside the container and no screen to put one on. Any tool that tries to open
|
||||||
|
a web page therefore fails, usually with something unhelpful like *"Couldn't find a suitable web
|
||||||
|
browser!"*. The **URL relay** fixes that: when a command inside the container asks for a browser,
|
||||||
|
the URL is handed to **your** browser on the host.
|
||||||
|
|
||||||
|
Nothing is displayed or forwarded from the container — only the URL travels.
|
||||||
|
|
||||||
|
It is always on and needs no configuration.
|
||||||
|
|
||||||
|
### What you see
|
||||||
|
|
||||||
|
A small bar appears at the top of the terminal reading **"Container asked to open a URL"**, with
|
||||||
|
the URL and an **Open** button. Click **Open** and the page loads in your normal browser, signed
|
||||||
|
in as you. The prompt disappears on its own after 30 seconds if you ignore it.
|
||||||
|
|
||||||
|
Triple-C asks rather than opening pages by itself. The container is sandboxed code — some of it
|
||||||
|
written by Claude a minute ago — and silently making your logged-in browser visit a URL it chose
|
||||||
|
is not something to hand over automatically. One click keeps that decision yours.
|
||||||
|
|
||||||
|
### Which commands benefit
|
||||||
|
|
||||||
|
Anything that opens a browser to authenticate or to show you a page:
|
||||||
|
|
||||||
|
| Command | What it wanted a browser for |
|
||||||
|
|---|---|
|
||||||
|
| `gh auth login` | GitHub device / OAuth login |
|
||||||
|
| `aws sso login` | AWS IAM Identity Center login |
|
||||||
|
| `gcloud auth login` | Google Cloud login |
|
||||||
|
| `az login` | Azure login |
|
||||||
|
| `vercel login`, `netlify login`, `fly auth login`, `heroku login`, `wrangler login` | Vendor CLI logins |
|
||||||
|
| `npm login`, `supabase login`, `doctl auth init` | Token / device flows |
|
||||||
|
| `xdg-open <url>` in any script | Opening a page directly |
|
||||||
|
| `python3 -m webbrowser <url>` | Anything using Python's `webbrowser` module |
|
||||||
|
|
||||||
|
Under the hood the container provides a stand-in browser at `/usr/local/bin/triple-c-open`,
|
||||||
|
installed under all the names tools look for — `xdg-open`, `sensible-browser`, `www-browser`,
|
||||||
|
`x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open` — and as the `$BROWSER`
|
||||||
|
environment variable, which most of the CLIs above consult first. You can also call
|
||||||
|
`triple-c-open <url>` yourself.
|
||||||
|
|
||||||
|
It works even when the command is run *by* Claude Code rather than typed by you: the relay talks
|
||||||
|
to the terminal directly, not through the command's output, so being nested inside a tool call
|
||||||
|
does not break it.
|
||||||
|
|
||||||
|
### When no terminal is attached
|
||||||
|
|
||||||
|
The relay rides on the terminal session. If nothing is attached to the container, there is nothing
|
||||||
|
to relay through:
|
||||||
|
|
||||||
|
- **Scheduled tasks** (Automation tab) run from cron with no terminal at all.
|
||||||
|
- A shell you opened with your own `docker exec`, outside Triple-C.
|
||||||
|
|
||||||
|
In those cases the relay does **not** hang or wait. It prints the URL in plain text and returns
|
||||||
|
immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
triple-c-open: no Triple-C terminal attached — cannot reach the host browser.
|
||||||
|
triple-c-open: open this URL manually:
|
||||||
|
https://github.com/login/device?user_code=WXYZ-1234
|
||||||
|
```
|
||||||
|
|
||||||
|
For a scheduled task that text lands in the task log (**Project Home → Automation → Logs**), so
|
||||||
|
you can still finish the login yourself afterwards. Practically speaking: don't expect an
|
||||||
|
unattended scheduled task to complete an interactive browser login. Authenticate once from a
|
||||||
|
terminal session — the credentials persist in the project's config volume — and let the scheduled
|
||||||
|
runs use them.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
Requests coming out of the container are treated as untrusted input, because that is what they
|
||||||
|
are:
|
||||||
|
|
||||||
|
- **Only `http://` and `https://` are ever opened.** `file://`, `javascript:`, `data:` and every
|
||||||
|
custom protocol handler your OS has registered are rejected outright. A container that could
|
||||||
|
make the host open arbitrary URI schemes would have a way out of the sandbox.
|
||||||
|
- URLs with embedded credentials (`https://github.com@evil.example/`) are rejected — they
|
||||||
|
misrepresent which site you are about to visit.
|
||||||
|
- Control characters, whitespace and oversized payloads are rejected before parsing, so the relay
|
||||||
|
cannot be used to smuggle terminal escape sequences into the UI.
|
||||||
|
- The URL is shown to you in its normalized form: what the prompt displays is exactly what opens.
|
||||||
|
- Prompts are rate-limited (a handful per ten seconds, with repeats of the same URL collapsed), so
|
||||||
|
a runaway loop in the container cannot bury the interface.
|
||||||
|
|
||||||
|
The relay only *asks*. Nothing opens without your click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Browser Logins Inside the Container (Auth Bridge)
|
## Browser Logins Inside the Container (Auth Bridge)
|
||||||
|
|
||||||
Some CLIs log you in by opening a browser and waiting for the browser to call back to a temporary
|
Some CLIs log you in by opening a browser and waiting for the browser to call back to a temporary
|
||||||
@@ -607,10 +804,13 @@ To use Claude Code with a local or remote Ollama server, set **Backend** to **Ol
|
|||||||
|
|
||||||
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
||||||
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
|
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
|
||||||
|
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||||
|
|
||||||
|
Global defaults for all three live under **Settings → Backends → Ollama Configuration** and are used whenever the matching per-project field is blank.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
|
Ollama natively implements the Anthropic Messages API at `POST /v1/messages`, which is the only thing Claude Code ever sends. Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication). The `ANTHROPIC_DEFAULT_*_MODEL` aliases are pinned to your model — see [Model Aliases and Background Calls](#model-aliases-and-background-calls).
|
||||||
|
|
||||||
> **Note:** Ollama 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.
|
> **Note:** Ollama 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.
|
||||||
|
|
||||||
@@ -618,29 +818,111 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OpenAI Compatible Configuration
|
## llama.cpp Configuration
|
||||||
|
|
||||||
To use Claude Code through any OpenAI API-compatible endpoint, set **Backend** to **OpenAI Compatible** under **Config → Model**. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others.
|
To use Claude Code with a local or remote `llama-server` (from [llama.cpp](https://github.com/ggml-org/llama.cpp)), set **Backend** to **llama.cpp** under **Config → Model**.
|
||||||
|
|
||||||
|
`llama-server` implements the Anthropic Messages API natively — `POST /v1/messages` and `POST /v1/messages/count_tokens` — so Claude Code talks to it directly, with no translation layer in between.
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
- **Base URL** — The URL of your OpenAI-compatible endpoint. Defaults to `http://host.docker.internal:4000` as an example (adjust to match your server's address and port).
|
- **Base URL** — The URL of your `llama-server`. Defaults to `http://host.docker.internal:8080`; **8080** is `llama-server`'s own default port (`--port PORT | port to listen (default: 8080)`). For a remote server, use its IP or hostname.
|
||||||
- **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
|
- **Model ID** — The model `llama-server` is serving. A `llama-server` process serves one model, so this is mostly the id Claude Code reports — but it is also what the model aliases are pinned to, so setting it matters.
|
||||||
- **Model ID** — Optional. Override the model to use.
|
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||||
|
|
||||||
|
Global defaults for all three live under **Settings → Backends → llama.cpp Configuration** and are used whenever the matching per-project field is blank.
|
||||||
|
|
||||||
|
### Starting llama-server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
llama-server -m /path/to/model.gguf --port 8080 --host 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`--host 0.0.0.0` matters: `llama-server` binds `127.0.0.1` by default, which the container cannot reach through `host.docker.internal`.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your OpenAI-compatible endpoint. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
Triple-C sets `ANTHROPIC_BASE_URL` to your `llama-server`, and `ANTHROPIC_AUTH_TOKEN` to the placeholder `llama.cpp`. `llama-server` only checks the `Authorization` header when it was started with `--api-key` (default: none), so the value is ignored in the usual case — but Claude Code requires *some* credential to be present, so one is always sent.
|
||||||
|
|
||||||
|
> **Note:** llama.cpp 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAI Compatible Configuration
|
||||||
|
|
||||||
|
To route Claude Code through a gateway, set **Backend** to **OpenAI Compatible** under **Config → Model**.
|
||||||
|
|
||||||
|
> **The name is misleading, and the distinction matters.** Claude Code only ever sends
|
||||||
|
> `POST /v1/messages?beta=true` in **Anthropic Messages** format to `ANTHROPIC_BASE_URL`. It never
|
||||||
|
> calls OpenAI's `/v1/chat/completions`. So this backend requires an endpoint that implements the
|
||||||
|
> **Anthropic Messages API** — **LiteLLM** does, and works. A server that exposes only an
|
||||||
|
> OpenAI-compatible API (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will
|
||||||
|
> **not** work here; put an Anthropic-shaped gateway such as LiteLLM in front of it.
|
||||||
|
> For Ollama and llama.cpp, use their own backends — both implement `/v1/messages` natively.
|
||||||
|
>
|
||||||
|
> (The backend name is kept as-is so existing projects keep working.)
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- **Base URL** — The URL of your gateway. Defaults to `http://host.docker.internal:4000`, LiteLLM's default port (adjust to match your server's address and port).
|
||||||
|
- **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
|
||||||
|
- **Model ID** — Optional. Override the model to use.
|
||||||
|
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||||
|
|
||||||
|
Global defaults for the base URL, model and background model live under **Settings → Backends → OpenAI Compatible Configuration**.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your gateway. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
||||||
|
|
||||||
> **Note:** 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 when routing to non-Anthropic models through the endpoint.
|
> **Note:** 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 when routing to non-Anthropic models through the endpoint.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Model Aliases and Background Calls
|
||||||
|
|
||||||
|
Claude Code has four model aliases — `opus`, `sonnet`, `haiku` and `fable`. Left alone they resolve
|
||||||
|
to **Anthropic's** model IDs. A local server has never heard of those IDs, so every call that goes
|
||||||
|
through an alias fails, usually with no visible error.
|
||||||
|
|
||||||
|
The one that bites hardest is `haiku`: `ANTHROPIC_DEFAULT_HAIKU_MODEL` is documented as *"Model ID
|
||||||
|
that the `haiku` alias resolves to, also used for background functionality"* — conversation titles,
|
||||||
|
summaries, and other out-of-band work. If it is wrong, those quietly stop happening.
|
||||||
|
|
||||||
|
So for every backend that points at a custom endpoint — **Ollama**, **llama.cpp** and **OpenAI
|
||||||
|
Compatible** — Triple-C sets all four:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | your configured **Model ID** |
|
||||||
|
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | your configured **Model ID** |
|
||||||
|
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | your **Background model**, or the **Model ID** if that is blank |
|
||||||
|
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | your configured **Model ID** |
|
||||||
|
|
||||||
|
**Leaving Background model blank is the right default.** A local server almost always serves one
|
||||||
|
model, and pointing every alias at it is what makes background work succeed.
|
||||||
|
|
||||||
|
Set **Background model** only if you serve a second, smaller model you would rather spend on titles
|
||||||
|
and summaries. It moves the Haiku alias alone; the other three still follow **Model ID**. It is
|
||||||
|
available per-project (Config → Model) and globally (Settings → Backends), with the usual
|
||||||
|
per-project-overrides-global rule.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- These variables are **not** set for the **Anthropic** or **Bedrock** backends. Those reach
|
||||||
|
servers that genuinely host the Anthropic model IDs, so Claude Code's own defaults are correct.
|
||||||
|
- All four names are reserved — you cannot set them yourself as custom environment variables.
|
||||||
|
- Changing a model or a Background model **recreates the container on the next start**, because
|
||||||
|
environment variables can only change at creation time.
|
||||||
|
- `ANTHROPIC_SMALL_FAST_MODEL`, the deprecated predecessor of the Haiku variable, is not used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
|
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
|
||||||
sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Git / SSH**,
|
sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Certificates**,
|
||||||
**Tools** and **Updates**.
|
**Git / SSH**, **Tools** and **Updates**.
|
||||||
|
|
||||||
### Claude Authentication
|
### Claude Authentication
|
||||||
|
|
||||||
@@ -670,6 +952,36 @@ Environment variables applied to **all** project containers. Per-project variabl
|
|||||||
|
|
||||||
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
|
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
|
||||||
|
|
||||||
|
### Corporate CA Certificate
|
||||||
|
|
||||||
|
If your organisation's network inspects TLS (a corporate proxy, a VPN that terminates HTTPS at the
|
||||||
|
edge), containers need your organisation's root certificate or **every** HTTPS call inside them
|
||||||
|
fails — `npm install`, `pip`, `git clone` over HTTPS, `curl`, the browser-view pane, and Claude
|
||||||
|
Code's own calls to the API.
|
||||||
|
|
||||||
|
Point this at either a **single certificate file** or a **folder** of them. It is mounted read-only
|
||||||
|
into every container and applied on every start, so it survives container recreation, base-image
|
||||||
|
migration and Reset — unlike a certificate you install by hand inside a running container, which is
|
||||||
|
lost the first time any of those happens.
|
||||||
|
|
||||||
|
The status line under the field tells you how many certificates were found and the names they will
|
||||||
|
be installed as inside the container. That rename matters: the container's trust store only reads
|
||||||
|
files ending in `.crt`, so a `.pem` is renamed rather than merely copied, which is the step that is
|
||||||
|
easiest to get wrong by hand.
|
||||||
|
|
||||||
|
Inside the container the certificate is trusted by:
|
||||||
|
|
||||||
|
| Consumer | How |
|
||||||
|
|---|---|
|
||||||
|
| curl, git, apt, wget | the system trust store (`update-ca-certificates`) |
|
||||||
|
| Node, npm, **Claude Code itself** | `NODE_EXTRA_CA_CERTS` |
|
||||||
|
| Python, pip, requests | `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` |
|
||||||
|
| Chrome / Chromium (browser view) | its own NSS database at `~/.pki/nssdb` |
|
||||||
|
|
||||||
|
A per-project override lives in **Project Home → Config → Access**; leave it blank to use this
|
||||||
|
global setting. Changing either recreates the project's container on its next start — replacing the
|
||||||
|
certificate file in place counts as a change, so a rotated CA is picked up too.
|
||||||
|
|
||||||
### Default Git Name / Email
|
### Default Git Name / Email
|
||||||
|
|
||||||
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
|
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
|
||||||
@@ -858,13 +1170,23 @@ triple-c-scheduler list # List all tasks
|
|||||||
triple-c-scheduler enable --id abc123 # Enable a task
|
triple-c-scheduler enable --id abc123 # Enable a task
|
||||||
triple-c-scheduler disable --id abc123 # Disable a task
|
triple-c-scheduler disable --id abc123 # Disable a task
|
||||||
triple-c-scheduler remove --id abc123 # Delete a task
|
triple-c-scheduler remove --id abc123 # Delete a task
|
||||||
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
|
||||||
|
triple-c-scheduler status # What is running right now, and for how long
|
||||||
|
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
|
||||||
triple-c-scheduler logs --id abc123 # View logs for a task
|
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||||
triple-c-scheduler notifications # View completion notifications
|
triple-c-scheduler notifications # View completion notifications
|
||||||
triple-c-scheduler notifications --clear # Clear notifications
|
triple-c-scheduler notifications --clear # Clear notifications
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`list` carries a status column, and the Automation tab marks a task **Running** with
|
||||||
|
its elapsed time, so a triggered run is visible rather than silent.
|
||||||
|
|
||||||
|
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
|
||||||
|
writes its answer in one go when it finishes, so a healthy run shows nothing but its
|
||||||
|
header for as long as it is thinking. `status` is what distinguishes a slow run from
|
||||||
|
a dead one — it reports the run only while the runner's process is genuinely alive.
|
||||||
|
|
||||||
### Cron Schedule Format
|
### Cron Schedule Format
|
||||||
|
|
||||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
@@ -900,6 +1222,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
|||||||
| **Ctrl+Tab** | Switch to the next tab |
|
| **Ctrl+Tab** | Switch to the next tab |
|
||||||
| **Ctrl+Shift+Tab** | Switch to the previous tab |
|
| **Ctrl+Shift+Tab** | Switch to the previous tab |
|
||||||
| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab |
|
| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab |
|
||||||
|
| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) |
|
||||||
|
|
||||||
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
|
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
|
||||||
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it
|
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it
|
||||||
@@ -940,7 +1263,13 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
|||||||
| build-essential | — | C/C++ compiler toolchain |
|
| build-essential | — | C/C++ compiler toolchain |
|
||||||
| openssh-client | — | SSH for git and remote access |
|
| openssh-client | — | SSH for git and remote access |
|
||||||
|
|
||||||
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||||
|
|
||||||
|
It also ships the **system libraries a browser needs to run** (`libnss3`, `libgbm1`, `libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, fonts, and the rest of the set Playwright asks for). So `npx playwright install chromium` gives you a browser that actually starts. Before these were baked in, that download succeeded and the browser then died with *"Host system is missing dependencies: libnss3.so"*, which is why `sudo apt install google-chrome-stable` looked like the cure — apt was quietly installing the same libraries as Chrome's own dependencies.
|
||||||
|
|
||||||
|
The **browsers themselves are not pre-installed** — they are hundreds of megabytes and tied to the Playwright version you use. Install one with the Browser tab's setup buttons, or `npx playwright install chromium` in a terminal. They land in `~/.cache/ms-playwright`, which is on the home volume, so a browser survives container recreation and base-image migration and is only lost on a project **Reset**.
|
||||||
|
|
||||||
|
If your project's container was created from an older base image, it won't have the libraries — the Browser tab's install action detects that and installs them for you first, and says so while it does. That install lives in the container's writable layer, so it is undone by a **Reset** and by a base-image migration; migrating the project onto the current base image is what picks the libraries up for good.
|
||||||
|
|
||||||
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||||
|
|
||||||
@@ -990,6 +1319,28 @@ These features are built into Claude Code and work inside Triple-C containers wi
|
|||||||
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
||||||
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
||||||
|
|
||||||
|
### "Couldn't find a suitable web browser" / a Command Won't Open a Page
|
||||||
|
|
||||||
|
The [URL relay](#opening-urls-in-your-browser-url-relay) should catch this. If a command still
|
||||||
|
complains, check from a terminal session in that project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "$BROWSER" # /usr/local/bin/triple-c-open
|
||||||
|
triple-c-open https://example.com/ # should raise the prompt in the terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
If `$BROWSER` is empty or `triple-c-open` is missing, the container is running an **older image**.
|
||||||
|
Rebuild it (Project Home → **Reset**, or pull/build the image again from Settings) — the relay is
|
||||||
|
part of the container image, not something the app can inject into a running container.
|
||||||
|
|
||||||
|
If you see *"no Triple-C terminal attached"*, the command is running somewhere with no terminal —
|
||||||
|
a scheduled task, or a shell you opened with your own `docker exec`. The URL is printed instead;
|
||||||
|
copy it into your browser. See
|
||||||
|
[When no terminal is attached](#when-no-terminal-is-attached).
|
||||||
|
|
||||||
|
If the prompt says the URL was refused, the command asked for a scheme the relay will not open on
|
||||||
|
your machine (anything that isn't `http`/`https`).
|
||||||
|
|
||||||
### A Browser Login Never Completes
|
### A Browser Login Never Completes
|
||||||
|
|
||||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||||
|
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||||
|
</picture>
|
||||||
|
|
||||||
# Triple-C (Claude-Code-Container)
|
# Triple-C (Claude-Code-Container)
|
||||||
|
|
||||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||||
|
|
||||||
|
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||||
|
|
||||||
|
| Document | For |
|
||||||
|
|---|---|
|
||||||
|
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||||
|
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||||
|
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||||
|
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||||
|
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||||
|
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||||
|
- [Permission Modes](#permission-modes)
|
||||||
|
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||||
|
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||||
|
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||||
|
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||||
|
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||||
@@ -29,6 +55,13 @@ two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a termi
|
|||||||
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
thing is current at a time.
|
thing is current at a time.
|
||||||
|
|
||||||
|
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||||
|
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||||
|
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||||
|
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||||
|
carries dropped *file paths*.
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||||
@@ -39,31 +72,34 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
|||||||
| `Ctrl+Shift+W` | Close the active tab |
|
| `Ctrl+Shift+W` | Close the active tab |
|
||||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||||
|
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||||
|
|
||||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||||
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||||
|
why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||||
|
|
||||||
### Project Home
|
### Project Home
|
||||||
|
|
||||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||||
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||||
Per-project configuration lives in the Config tab rather than in modals.
|
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||||
| **Files** | Browse, download and upload files inside the container |
|
| **Files** | Browse, download and upload files inside the container |
|
||||||
|
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||||
|
|
||||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||||
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
progress modal.
|
progress modal.
|
||||||
|
|
||||||
### Permission Modes
|
## Permission Modes
|
||||||
|
|
||||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||||
@@ -87,15 +123,250 @@ back into flags for its headless `claude -p` run. Because it travels as containe
|
|||||||
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
forces that).
|
forces that).
|
||||||
|
|
||||||
### Container Introspection (Capability Tiles)
|
## Containers
|
||||||
|
|
||||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
### Container Lifecycle
|
||||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
|
||||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
|
||||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
|
||||||
|
|
||||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||||
|
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||||
|
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||||
|
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||||
|
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||||
|
|
||||||
|
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||||
|
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||||
|
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||||
|
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||||
|
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||||
|
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||||
|
image.
|
||||||
|
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||||
|
|
||||||
|
### Base-Image Migration
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||||
|
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||||
|
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||||
|
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||||
|
|
||||||
|
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||||
|
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||||
|
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||||
|
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||||
|
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||||
|
"stale".
|
||||||
|
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||||
|
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||||
|
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||||
|
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||||
|
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||||
|
skills, transcripts and scheduler tasks simply re-attach.
|
||||||
|
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||||
|
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||||
|
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||||
|
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||||
|
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||||
|
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||||
|
report.
|
||||||
|
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||||
|
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||||
|
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||||
|
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||||
|
`$HOME` during a migrated session survives it.
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
| Target in Container | Source | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||||
|
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||||
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
|
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||||
|
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||||
|
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||||
|
removal, and by nothing else.
|
||||||
|
|
||||||
|
### Corporate CA Certificates
|
||||||
|
|
||||||
|
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||||
|
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||||
|
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||||
|
every start — so it survives recreation, migration and Reset.
|
||||||
|
|
||||||
|
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||||
|
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||||
|
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||||
|
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||||
|
sees a directory.
|
||||||
|
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||||
|
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||||
|
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||||
|
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||||
|
rather than failing the start.
|
||||||
|
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||||
|
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||||
|
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||||
|
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||||
|
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||||
|
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||||
|
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||||
|
so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
|
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||||
|
|
||||||
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
|
### Docker Socket Path
|
||||||
|
|
||||||
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
|
## Models and Authentication
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
Each project can independently use one of:
|
||||||
|
|
||||||
|
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||||
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
|
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||||
|
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||||
|
|
||||||
|
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||||
|
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||||
|
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||||
|
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||||
|
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||||
|
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||||
|
> they get first-class backends of their own rather than going through a translation layer.
|
||||||
|
|
||||||
|
#### Model alias variables
|
||||||
|
|
||||||
|
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||||
|
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||||
|
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||||
|
|
||||||
|
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||||
|
Triple-C therefore sets all four:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||||
|
|
||||||
|
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||||
|
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||||
|
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||||
|
|
||||||
|
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||||
|
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||||
|
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||||
|
|
||||||
|
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
|
|
||||||
|
### Model Gateway (LiteLLM sibling container)
|
||||||
|
|
||||||
|
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||||
|
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||||
|
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||||
|
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||||
|
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||||
|
lifecycle, including auto-start with the app.
|
||||||
|
|
||||||
|
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||||
|
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||||
|
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||||
|
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||||
|
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||||
|
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||||
|
**always** set, because LiteLLM without one accepts any key.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||||
|
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||||
|
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||||
|
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||||
|
anything resembling an `sk-ant-` secret.
|
||||||
|
|
||||||
|
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||||
|
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||||
|
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||||
|
|
||||||
|
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||||
|
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||||
|
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||||
|
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||||
|
it cleared.
|
||||||
|
|
||||||
|
## Bridges to the Host
|
||||||
|
|
||||||
|
### URL Relay (host browser)
|
||||||
|
|
||||||
|
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
|
### Auth Bridge
|
||||||
|
|
||||||
@@ -121,63 +392,54 @@ recreates the container. The poller stops on its own when the container stops.
|
|||||||
unauthenticated service inside the container, so widening those addresses would publish container
|
unauthenticated service inside the container, so widening those addresses would publish container
|
||||||
internals to the LAN. Nothing else on the network can reach a bridged port.
|
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||||
|
|
||||||
### Shared Claude Authentication Token
|
### Browser View
|
||||||
|
|
||||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
per project.
|
||||||
anything resembling an `sk-ant-` secret.
|
|
||||||
|
|
||||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||||
|
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||||
|
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||||
|
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||||
|
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||||
|
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||||
|
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||||
|
iframe while popped out, and both viewers can drive the browser.
|
||||||
|
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||||
|
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||||
|
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||||
|
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||||
|
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||||
|
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||||
|
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||||
|
continuously and each event costs a container exec.
|
||||||
|
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||||
|
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||||
|
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||||
|
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||||
|
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||||
|
recreation *and* migration.
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||||
|
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||||
|
once Playwright is present — not a setup route.
|
||||||
|
|
||||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
## Inside a Project
|
||||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
|
||||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
|
||||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
|
||||||
it cleared.
|
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Introspection (Capability Tiles)
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
|
||||||
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
|
||||||
|
|
||||||
### Mounts
|
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.
|
||||||
| Target in Container | Source | Type | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
|
||||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
|
||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
|
||||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
|
||||||
|
|
||||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
|
||||||
removal, and by nothing else.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
|
||||||
|
|
||||||
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
|
||||||
|
|
||||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
|
||||||
|
|
||||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
|
||||||
|
|
||||||
### Mission Control Integration
|
### Mission Control Integration
|
||||||
|
|
||||||
@@ -202,85 +464,117 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||||
- **Port**: Default `9876` (configurable)
|
- **Port**: Default `9876` (configurable)
|
||||||
|
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||||
- **Language**: Optional language hint for transcription
|
- **Language**: Optional language hint for transcription
|
||||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||||
|
|
||||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||||
|
|
||||||
### Docker Socket Path
|
|
||||||
|
|
||||||
The socket path is OS-aware:
|
|
||||||
- **Linux/macOS**: `/var/run/docker.sock`
|
|
||||||
- **Windows**: `//./pipe/docker_engine`
|
|
||||||
|
|
||||||
Users can override this in Settings via the global `docker_socket_path` option.
|
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
|
### Frontend — layout and projects
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
|
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||||
|
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||||
|
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||||
|
|
||||||
|
### Frontend — Project Home
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||||
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||||
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||||
|
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||||
|
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||||
|
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||||
|
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
|
||||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
### Frontend — settings, terminal and hooks
|
||||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||||
|
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||||
|
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
| `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/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||||
|
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||||
|
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||||
|
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
|
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||||
|
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||||
|
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||||
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||||
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||||
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||||
|
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||||
|
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||||
|
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||||
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||||
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||||
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
|
||||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
### Container and packaging
|
||||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
|
||||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
| File | Purpose |
|
||||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
|---|---|
|
||||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
|
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||||
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||||
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
|
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||||
|
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||||
|
|
||||||
## CSS / Styling Notes
|
## CSS / Styling Notes
|
||||||
|
|
||||||
@@ -293,8 +587,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
|
|
||||||
**Base**: Ubuntu 24.04
|
**Base**: Ubuntu 24.04
|
||||||
|
|
||||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||||
|
|
||||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `rec`/`arecord` (audio FIFO for voice mode)
|
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||||
|
|
||||||
|
**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)
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
|
|||||||
@@ -218,8 +218,21 @@ Each project independently chooses one backend:
|
|||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **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 |
|
| **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 |
|
||||||
| **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 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 |
|
||||||
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to a placeholder. | Local models (best-effort) |
|
| **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) |
|
||||||
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, …). | Gateways and proxies (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
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
@@ -232,9 +245,31 @@ minutes.
|
|||||||
|
|
||||||
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
- **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.
|
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
|
- **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
|
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.
|
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
|
- **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
|
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
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
@@ -443,7 +478,8 @@ triple-c/
|
|||||||
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||||
│ │ # editors reused by Project Home
|
│ │ # editors reused by Project Home
|
||||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||||
│ │ # OllamaSettings, OpenAiCompatibleSettings,
|
│ │ # OllamaSettings, LlamaCppSettings,
|
||||||
|
│ │ # OpenAiCompatibleSettings,
|
||||||
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
||||||
│ │ # WebTerminalSettings, SttSettings,
|
│ │ # WebTerminalSettings, SttSettings,
|
||||||
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
||||||
|
|||||||
@@ -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,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 |
@@ -5163,7 +5163,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -38,6 +38,11 @@ base64 = "0.22"
|
|||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
|
||||||
|
[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 = [] }
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 16 KiB |
@@ -56,7 +56,7 @@ use tokio::sync::{watch, Mutex};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::docker::container::is_container_running;
|
use crate::docker::container::is_container_running;
|
||||||
use crate::docker::exec::exec_oneshot;
|
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||||
use crate::storage::projects_store::ProjectsStore;
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
use proc_net::PortFamily;
|
use proc_net::PortFamily;
|
||||||
@@ -248,9 +248,14 @@ impl AuthBridgeManager {
|
|||||||
/// project whose bridge is on but whose container is stopped still reports
|
/// project whose bridge is on but whose container is stopped still reports
|
||||||
/// `enabled: true` with no active ports.
|
/// `enabled: true` with no active ports.
|
||||||
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||||
let map = self.bridges.lock().await;
|
// Clone the per-project handle out and drop the map lock before taking
|
||||||
match map.get(project_id) {
|
// the state lock. Holding both across the nested await is not a
|
||||||
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
|
// 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 {
|
None => AuthBridgeStatus {
|
||||||
enabled,
|
enabled,
|
||||||
..AuthBridgeStatus::disabled()
|
..AuthBridgeStatus::disabled()
|
||||||
@@ -300,8 +305,16 @@ async fn poll_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One exec per tick reads both procfs files.
|
// 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![
|
let cmd = vec![
|
||||||
"cat".to_string(),
|
"/usr/bin/cat".to_string(),
|
||||||
"/proc/net/tcp".to_string(),
|
"/proc/net/tcp".to_string(),
|
||||||
"/proc/net/tcp6".to_string(),
|
"/proc/net/tcp6".to_string(),
|
||||||
];
|
];
|
||||||
@@ -309,7 +322,7 @@ async fn poll_loop(
|
|||||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||||
let discovery = tokio::select! {
|
let discovery = tokio::select! {
|
||||||
_ = cancel.changed() => break,
|
_ = cancel.changed() => break,
|
||||||
res = exec_oneshot(&container_id, cmd) => res,
|
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||||
};
|
};
|
||||||
|
|
||||||
match discovery {
|
match discovery {
|
||||||
@@ -362,14 +375,71 @@ async fn poll_loop(
|
|||||||
/// Ports Docker already handles for this project. A container port that is
|
/// Ports Docker already handles for this project. A container port that is
|
||||||
/// explicitly published has a host-side path already, and the mapping's host
|
/// explicitly published has a host-side path already, and the mapping's host
|
||||||
/// port is a binding we must not fight over.
|
/// port is a binding we must not fight over.
|
||||||
|
///
|
||||||
|
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||||
|
/// loopback listeners another feature owns and exposes on its own,
|
||||||
|
/// authenticated terms.
|
||||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||||
project
|
let mut skip: HashSet<u16> = project
|
||||||
.port_mappings
|
.port_mappings
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| [m.container_port, m.host_port])
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
.collect()
|
.collect();
|
||||||
|
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||||
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||||
|
skip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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
|
/// Bring the set of host listeners in line with what the container is currently
|
||||||
/// listening on. Returns whether anything the UI cares about changed.
|
/// listening on. Returns whether anything the UI cares about changed.
|
||||||
async fn reconcile(
|
async fn reconcile(
|
||||||
@@ -412,6 +482,20 @@ async fn reconcile(
|
|||||||
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||||
continue;
|
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 {
|
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||||
Ok(forward) => {
|
Ok(forward) => {
|
||||||
if st.conflicts.remove(&port).is_some() {
|
if st.conflicts.remove(&port).is_some() {
|
||||||
@@ -438,9 +522,8 @@ async fn reconcile(
|
|||||||
);
|
);
|
||||||
if st.conflicts.get(&port) != Some(&reason) {
|
if st.conflicts.get(&port) != Some(&reason) {
|
||||||
log::warn!("Auth bridge: {}", reason);
|
log::warn!("Auth bridge: {}", reason);
|
||||||
st.conflicts.insert(port, reason);
|
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
|
changed |= note_conflict(&mut st, port, reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,6 +531,23 @@ async fn reconcile(
|
|||||||
changed
|
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
|
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||||
/// return nothing is bound.
|
/// return nothing is bound.
|
||||||
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||||
@@ -519,7 +619,84 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_mappings_means_nothing_is_skipped() {
|
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||||
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
|
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||||
|
assert_eq!(
|
||||||
|
skip.len(),
|
||||||
|
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_browser_views_host_ports_are_never_taken() {
|
||||||
|
// The bridge binds *host* ports chosen by the container, so without
|
||||||
|
// this it can take the port the browser-view proxy will want later —
|
||||||
|
// that pane binds on demand, so first-come would win.
|
||||||
|
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||||
|
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 = skipped_ports(&project_with_mappings(vec![]));
|
||||||
|
for port in RESERVED_CONTAINER_PORTS {
|
||||||
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||||
|
|
||||||
|
// Reservations coexist with Docker's own published ports.
|
||||||
|
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||||
|
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,24 @@ async fn accept_optional(
|
|||||||
|
|
||||||
/// Carry one accepted host connection into the container over `socat`.
|
/// Carry one accepted host connection into the container over `socat`.
|
||||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||||
|
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||||
|
|
||||||
let AttachedExec {
|
let AttachedExec {
|
||||||
@@ -198,6 +216,13 @@ async fn tunnel_connection(container_id: String, target: String, stream: TcpStre
|
|||||||
// direction drops `input`, which closes the exec's stdin and lets socat see
|
// 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).
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||||
let upstream = AbortOnDrop(tokio::spawn(async move {
|
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];
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
loop {
|
loop {
|
||||||
match host_rx.read(&mut buf).await {
|
match host_rx.read(&mut buf).await {
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! 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.
|
||||||
|
#[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 {
|
||||||
|
// Awaits the supervisor, so the host port is released before we return.
|
||||||
|
manager().stop(&project_id).await;
|
||||||
|
return Ok(manager().status(&project_id).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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status. Cheap: reads in-process state only, never the container.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
||||||
|
Ok(manager().status(&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).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).await;
|
||||||
|
if status.state != BrowserViewState::Running {
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Starting the viewer…",
|
||||||
|
);
|
||||||
|
manager()
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||||
|
// window of its own or it lands somewhere the user isn't looking.
|
||||||
|
if show_window {
|
||||||
|
let status = manager().status(&project_id).await;
|
||||||
|
if let Some(url) = status.url.as_deref() {
|
||||||
|
let name = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "Triple-C".to_string());
|
||||||
|
popout::open(&app_handle, &project_id, &name, url, false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the page this opened. The pop-out's "match window" mode calls this on
|
||||||
|
/// every settled resize, so it is deliberately cheap: one control-file write.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_container_page_viewport(
|
||||||
|
project_id: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_container_page_state(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||||
|
return Ok(page::PageState::default());
|
||||||
|
};
|
||||||
|
Ok(page::state(&container_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the page this opened, leaving the view itself running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_container_page(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||||
|
page::close(&container_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the page track the pop-out window's size as it is dragged.
|
||||||
|
///
|
||||||
|
/// Only affects a page **this app opened**: a bound browser admits no second
|
||||||
|
/// client, so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||||
|
/// Turning it on applies the window's current size immediately, so the toggle
|
||||||
|
/// has a visible effect without waiting for a drag.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_match_window(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_match_window(&project_id, enabled);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||||
|
/// pop-out's state — the pane is unmounted whenever another sub-tab is shown.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, String> {
|
||||||
|
Ok(popout::match_window(&project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project's container, or a sentence saying why there isn't one.
|
||||||
|
///
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
@@ -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,863 @@
|
|||||||
|
//! 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, exactly like `auth_bridge_enabled`.
|
||||||
|
//! One supervisor task per session owns the proxy and the viewer process, and it
|
||||||
|
//! is the only thing that tears them down, so every way a session can end funnels
|
||||||
|
//! through one code path:
|
||||||
|
//!
|
||||||
|
//! | Trigger | Path |
|
||||||
|
//! |---|---|
|
||||||
|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
|
||||||
|
//! | 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.
|
||||||
|
|
||||||
|
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. Off by default.
|
||||||
|
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>>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct BrowserViewManager {
|
||||||
|
sessions: SessionMap,
|
||||||
|
/// The per-project opt-in.
|
||||||
|
///
|
||||||
|
/// NOTE: in memory only, so it does not survive an app restart. The durable
|
||||||
|
/// home for this is a `browser_view_enabled: bool` field on
|
||||||
|
/// `models::Project` (see the report) — `models/project.rs` is out of scope
|
||||||
|
/// for this change, so the flag lives here and the wiring is otherwise
|
||||||
|
/// identical to `auth_bridge_enabled`.
|
||||||
|
enabled: Mutex<std::collections::HashSet<String>>,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
pub async fn is_enabled(&self, project_id: &str) -> bool {
|
||||||
|
self.enabled.lock().await.contains(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_enabled(&self, project_id: &str, enabled: bool) {
|
||||||
|
let mut set = self.enabled.lock().await;
|
||||||
|
if enabled {
|
||||||
|
set.insert(project_id.to_string());
|
||||||
|
} else {
|
||||||
|
set.remove(project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status without touching the container.
|
||||||
|
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
|
||||||
|
let enabled = self.is_enabled(project_id).await;
|
||||||
|
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.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) -> Result<BrowserViewStatus, String> {
|
||||||
|
self.set_enabled(&project_id, true).await;
|
||||||
|
|
||||||
|
// 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).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 = pick_viewer_port(&container_id).await?;
|
||||||
|
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
||||||
|
|
||||||
|
// Wait for it to actually answer, and learn the entry URL while we're
|
||||||
|
// there — see `probe_entry_path` for why that matters. This, not the
|
||||||
|
// launcher's stdout, is the readiness signal: verified that the
|
||||||
|
// "Listening on …" line is printed only on the very first start.
|
||||||
|
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(e) => {
|
||||||
|
let log = read_viewer_log(&container_id).await;
|
||||||
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
return Err(explain_start_failure(&e, &log));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let 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).await;
|
||||||
|
emit(&app, &project_id, &status);
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop one project's view and wait until its host port has been released.
|
||||||
|
pub async fn stop(&self, project_id: &str) {
|
||||||
|
self.set_enabled(project_id, false).await;
|
||||||
|
// Remove under the lock, then release it before awaiting: the
|
||||||
|
// supervisor takes the same lock to deregister itself on exit.
|
||||||
|
let session = self.sessions.lock().await.remove(project_id);
|
||||||
|
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);
|
||||||
|
|
||||||
|
let enabled = manager().is_enabled(&project_id).await;
|
||||||
|
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
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
||||||
|
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||||
|
let text = exec_oneshot(
|
||||||
|
container_id,
|
||||||
|
vec![
|
||||||
|
"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))
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,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()
|
||||||
|
}
|
||||||
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
|
|||||||
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||||
/// expressions are not evaluated here.
|
/// expressions are not evaluated here.
|
||||||
pub next_run: Option<String>,
|
pub next_run: Option<String>,
|
||||||
|
/// Whether a run is in flight right now, from the runner's state file in
|
||||||
|
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
|
||||||
|
pub running: bool,
|
||||||
|
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
|
||||||
|
pub running_since: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
||||||
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
|
|||||||
set -u
|
set -u
|
||||||
TASKS="$HOME/.claude/scheduler/tasks"
|
TASKS="$HOME/.claude/scheduler/tasks"
|
||||||
LOGS="$HOME/.claude/scheduler/logs"
|
LOGS="$HOME/.claude/scheduler/logs"
|
||||||
|
RUNNING="$HOME/.claude/scheduler/running"
|
||||||
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
||||||
for f in "$TASKS"/*.json; do
|
for f in "$TASKS"/*.json; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
id=$(jq -r '.id // ""' "$f") || continue
|
id=$(jq -r '.id // ""' "$f") || continue
|
||||||
[ -n "$id" ] || id=$(basename "$f" .json)
|
[ -n "$id" ] || id=$(basename "$f" .json)
|
||||||
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
||||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{
|
# Live-run state. The pid is checked, not trusted: a container stopped
|
||||||
|
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
|
||||||
|
# "running" forever is a worse lie than showing nothing.
|
||||||
|
started=""
|
||||||
|
state="$RUNNING/$id.json"
|
||||||
|
if [ -f "$state" ]; then
|
||||||
|
pid=$(jq -r '.pid // empty' "$state")
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
started=$(jq -r '.started_epoch // empty' "$state")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
|
||||||
id: (if (.id // "") == "" then $fallback_id else .id end),
|
id: (if (.id // "") == "" then $fallback_id else .id end),
|
||||||
name: (.name // ""),
|
name: (.name // ""),
|
||||||
prompt: (.prompt // ""),
|
prompt: (.prompt // ""),
|
||||||
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
|
|||||||
enabled: (.enabled == true),
|
enabled: (.enabled == true),
|
||||||
working_dir: (.working_dir // "/workspace"),
|
working_dir: (.working_dir // "/workspace"),
|
||||||
created_at: (.created_at // null),
|
created_at: (.created_at // null),
|
||||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end)
|
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
|
||||||
|
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
|
||||||
}' "$f"
|
}' "$f"
|
||||||
done | jq -s 'sort_by(.name, .id)'
|
done | jq -s 'sort_by(.name, .id)'
|
||||||
"#;
|
"#;
|
||||||
@@ -673,6 +691,7 @@ struct RawScheduledTask {
|
|||||||
working_dir: String,
|
working_dir: String,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
last_run_epoch: Option<i64>,
|
last_run_epoch: Option<i64>,
|
||||||
|
running_since_epoch: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
|
|||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
last_run: t.last_run_epoch.map(epoch_to_iso),
|
last_run: t.last_run_epoch.map(epoch_to_iso),
|
||||||
next_run,
|
next_run,
|
||||||
|
running: t.running_since_epoch.is_some(),
|
||||||
|
running_since: t.running_since_epoch.map(epoch_to_iso),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ pub mod auth_token_commands;
|
|||||||
pub mod aws_commands;
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
|
pub mod gateway_commands;
|
||||||
pub mod help_commands;
|
pub mod help_commands;
|
||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
|
pub mod migration_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use tauri::{Emitter, State};
|
|||||||
|
|
||||||
use crate::commands::aws_commands;
|
use crate::commands::aws_commands;
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"container-progress",
|
"container-progress",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -43,8 +43,50 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create the project's container, threading every global setting through.
|
||||||
|
///
|
||||||
|
/// Exists so that the two ordinary create paths below and base-image migration
|
||||||
|
/// cannot drift apart — a container created by a migration must be
|
||||||
|
/// indistinguishable from one created by a normal start, or the next
|
||||||
|
/// `container_needs_recreation` would immediately throw it away.
|
||||||
|
///
|
||||||
|
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||||
|
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||||
|
/// order to tell those two apart when it stamps the lineage labels.
|
||||||
|
pub(crate) async fn create_container_for_project(
|
||||||
|
project: &Project,
|
||||||
|
settings: &AppSettings,
|
||||||
|
docker_socket: &str,
|
||||||
|
aws_config_path: Option<&str>,
|
||||||
|
create_image: &str,
|
||||||
|
base_image_name: &str,
|
||||||
|
extras: docker::CreateExtras<'_>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
docker::create_container(
|
||||||
|
project,
|
||||||
|
docker_socket,
|
||||||
|
create_image,
|
||||||
|
base_image_name,
|
||||||
|
extras,
|
||||||
|
aws_config_path,
|
||||||
|
&settings.global_aws,
|
||||||
|
&settings.global_ollama,
|
||||||
|
&settings.global_llamacpp,
|
||||||
|
&settings.global_openai_compatible,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
|
settings.global_claude_code_settings.as_ref(),
|
||||||
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
|
settings.default_git_user_name.as_deref(),
|
||||||
|
settings.default_git_user_email.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Populate secret fields on a project struct from the OS keychain.
|
/// Populate secret fields on a project struct from the OS keychain.
|
||||||
fn load_secrets_for_project(project: &mut Project) {
|
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
if let Some(ref mut bedrock) = project.bedrock_config {
|
if let Some(ref mut bedrock) = project.bedrock_config {
|
||||||
@@ -104,6 +146,11 @@ pub async fn remove_project(
|
|||||||
// before the container (and the project record) go away.
|
// before the container (and the project record) go away.
|
||||||
state.auth_bridge.stop(&project_id).await;
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
|
// A migration record outliving its project leaks a state file, a staged
|
||||||
|
// payload tar that can run to several GB, and a `:pre-migration-<ts>` tag
|
||||||
|
// holding an entire snapshot image that nothing will ever reference again.
|
||||||
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists
|
||||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
@@ -174,6 +221,20 @@ pub async fn start_project_container(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
|
// A migration removes the container and creates its replacement moments
|
||||||
|
// later. Starting in that window finds no container, creates a second one
|
||||||
|
// under the same name, and the migration's own create then fails on the
|
||||||
|
// name conflict — which sends it into an auto-rollback that also cannot
|
||||||
|
// create. The UI already refuses (`canMigrate` gates on the container being
|
||||||
|
// stopped and no run being in flight); this is the same gate on the side
|
||||||
|
// that actually owns the invariant.
|
||||||
|
if crate::commands::migration_commands::is_migrating(&project_id) {
|
||||||
|
return Err(
|
||||||
|
"A container base update is running for this project. Wait for it to finish, then start the project."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut project = state
|
let mut project = state
|
||||||
.projects_store
|
.projects_store
|
||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
@@ -207,6 +268,16 @@ pub async fn start_project_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if project.backend == Backend::LlamaCpp {
|
||||||
|
let cfg = project.llamacpp_config.as_ref()
|
||||||
|
.ok_or_else(|| "llama.cpp backend selected but no llama.cpp configuration found.".to_string())?;
|
||||||
|
if cfg.base_url.trim().is_empty()
|
||||||
|
&& settings.global_llamacpp.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||||
|
{
|
||||||
|
return Err("llama.cpp base URL is required. Set it per-project or in global llama.cpp settings.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if project.backend == Backend::OpenAiCompatible {
|
if project.backend == Backend::OpenAiCompatible {
|
||||||
let oai_config = project.openai_compatible_config.as_ref()
|
let oai_config = project.openai_compatible_config.as_ref()
|
||||||
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
||||||
@@ -307,19 +378,36 @@ pub async fn start_project_container(
|
|||||||
// AWS config path from global settings
|
// AWS config path from global settings
|
||||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||||
|
|
||||||
|
// What we would create this container from *right now*: the project's
|
||||||
|
// snapshot when one exists, else the configured base. This is the value
|
||||||
|
// `container_needs_recreation` compares against the container's
|
||||||
|
// `triple-c.create-image` label — the check that replaced the old
|
||||||
|
// tautological one. It is resolved *before* the commit below, so it
|
||||||
|
// describes the pre-commit world the existing container was born into.
|
||||||
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||||
|
let expected_create_image =
|
||||||
|
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
|
snapshot_image.clone()
|
||||||
|
} else {
|
||||||
|
image_name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||||
// Check if config changed — if so, snapshot + recreate
|
// Check if config changed — if so, snapshot + recreate
|
||||||
let needs_recreate = docker::container_needs_recreation(
|
let needs_recreate = docker::container_needs_recreation(
|
||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
|
&expected_create_image,
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
&settings.global_ollama,
|
&settings.global_ollama,
|
||||||
|
&settings.global_llamacpp,
|
||||||
&settings.global_openai_compatible,
|
&settings.global_openai_compatible,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
settings.global_claude_code_settings.as_ref(),
|
settings.global_claude_code_settings.as_ref(),
|
||||||
settings.default_ssh_key_path.as_deref(),
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
settings.default_git_user_name.as_deref(),
|
settings.default_git_user_name.as_deref(),
|
||||||
settings.default_git_user_email.as_deref(),
|
settings.default_git_user_email.as_deref(),
|
||||||
).await.unwrap_or(false);
|
).await.unwrap_or(false);
|
||||||
@@ -341,32 +429,39 @@ pub async fn start_project_container(
|
|||||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
docker::remove_legacy_project_network(&project.id).await;
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
|
|
||||||
// Create from snapshot image (preserves system-level changes)
|
// Create from snapshot image (preserves system-level changes).
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
// Re-resolved after the commit above: when no snapshot existed
|
||||||
|
// before, one does now, and creating from the base instead
|
||||||
|
// would throw away the state that was just saved.
|
||||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
snapshot_image
|
snapshot_image.clone()
|
||||||
} else {
|
} else {
|
||||||
image_name.clone()
|
image_name.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_id = docker::create_container(
|
let new_id = create_container_for_project(
|
||||||
&project,
|
&project,
|
||||||
|
&settings,
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&create_image,
|
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&create_image,
|
||||||
&settings.global_ollama,
|
&image_name,
|
||||||
&settings.global_openai_compatible,
|
docker::CreateExtras::default(),
|
||||||
settings.global_claude_instructions.as_deref(),
|
|
||||||
&settings.global_custom_env_vars,
|
|
||||||
settings.timezone.as_deref(),
|
|
||||||
settings.global_claude_code_settings.as_ref(),
|
|
||||||
settings.default_ssh_key_path.as_deref(),
|
|
||||||
settings.default_git_user_name.as_deref(),
|
|
||||||
settings.default_git_user_email.as_deref(),
|
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
|
|
||||||
|
// The commit above moved `:latest` and orphaned the image it
|
||||||
|
// used to point at; the container holding that image open was
|
||||||
|
// removed a few lines up, so now is when Docker will actually
|
||||||
|
// let it go. Detached because this is housekeeping and the
|
||||||
|
// project is already running — and it sweeps every orphan, not
|
||||||
|
// just this one, so recreations that happened before the sweep
|
||||||
|
// existed are cleaned up too.
|
||||||
|
tauri::async_runtime::spawn(async {
|
||||||
|
docker::sweep_orphaned_snapshots().await;
|
||||||
|
});
|
||||||
|
|
||||||
new_id
|
new_id
|
||||||
} else {
|
} else {
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
@@ -377,30 +472,20 @@ pub async fn start_project_container(
|
|||||||
// Container doesn't exist (first start, or Docker pruned it).
|
// Container doesn't exist (first start, or Docker pruned it).
|
||||||
// Check for a snapshot image first — it preserves system-level
|
// Check for a snapshot image first — it preserves system-level
|
||||||
// changes (apt/pip/npm installs) from the previous session.
|
// changes (apt/pip/npm installs) from the previous session.
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
if expected_create_image == snapshot_image {
|
||||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
|
||||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||||
snapshot_image
|
}
|
||||||
} else {
|
let create_image = expected_create_image.clone();
|
||||||
image_name.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||||
let new_id = docker::create_container(
|
let new_id = create_container_for_project(
|
||||||
&project,
|
&project,
|
||||||
|
&settings,
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&create_image,
|
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&create_image,
|
||||||
&settings.global_ollama,
|
&image_name,
|
||||||
&settings.global_openai_compatible,
|
docker::CreateExtras::default(),
|
||||||
settings.global_claude_instructions.as_deref(),
|
|
||||||
&settings.global_custom_env_vars,
|
|
||||||
settings.timezone.as_deref(),
|
|
||||||
settings.global_claude_code_settings.as_ref(),
|
|
||||||
settings.default_ssh_key_path.as_deref(),
|
|
||||||
settings.default_git_user_name.as_deref(),
|
|
||||||
settings.default_git_user_email.as_deref(),
|
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
@@ -484,11 +569,28 @@ pub async fn rebuild_project_container(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
|
// Reset deletes both volumes and the snapshot image. Doing that while a
|
||||||
|
// migration is mid-flight pulls the ground out from under it and leaves an
|
||||||
|
// orphan migration record pointing at images that no longer exist.
|
||||||
|
if crate::commands::migration_commands::is_migrating(&project_id) {
|
||||||
|
return Err(
|
||||||
|
"A container base update is running for this project. Wait for it to finish before resetting."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let project = state
|
let project = state
|
||||||
.projects_store
|
.projects_store
|
||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
// Reset supersedes any migration decision that was still pending: the
|
||||||
|
// snapshot image and both volumes are about to go, so a surviving record
|
||||||
|
// could only describe things that no longer exist — while its
|
||||||
|
// `:pre-migration-<ts>` tag held a whole snapshot image (multiple GB) alive
|
||||||
|
// with nothing left that could ever use it.
|
||||||
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
// The bridge is bound to the container that is about to be destroyed;
|
// The bridge is bound to the container that is about to be destroyed;
|
||||||
// `start_project_container` below re-arms it against the new one.
|
// `start_project_container` below re-arms it against the new one.
|
||||||
state.auth_bridge.stop(&project_id).await;
|
state.auth_bridge.stop(&project_id).await;
|
||||||
@@ -517,6 +619,13 @@ pub async fn rebuild_project_container(
|
|||||||
/// Called by the frontend after Docker is confirmed available. Projects
|
/// Called by the frontend after Docker is confirmed available. Projects
|
||||||
/// marked as Running whose containers are no longer running get reset
|
/// marked as Running whose containers are no longer running get reset
|
||||||
/// to Stopped.
|
/// to Stopped.
|
||||||
|
///
|
||||||
|
/// This is also where an interrupted **base-image migration** is picked up.
|
||||||
|
/// It runs at startup, which is exactly when a migration that died with the app
|
||||||
|
/// needs to be noticed — see
|
||||||
|
/// [`crate::commands::migration_commands::reconcile_migration`]. The migration
|
||||||
|
/// pass runs over *every* project, not just the Running ones, because a project
|
||||||
|
/// whose container was removed mid-migration reports Stopped.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reconcile_project_statuses(
|
pub async fn reconcile_project_statuses(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -525,7 +634,31 @@ pub async fn reconcile_project_statuses(
|
|||||||
let projects = state.projects_store.list();
|
let projects = state.projects_store.list();
|
||||||
|
|
||||||
for project in &projects {
|
for project in &projects {
|
||||||
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
for project in &projects {
|
||||||
|
// `Starting` and `Stopping` are in here as a backstop, not because
|
||||||
|
// anything is expected to leave a project in one. They are transitional
|
||||||
|
// states owned by an in-flight command, so a project still wearing one
|
||||||
|
// is a project whose command died — a crash mid-start, or a migration
|
||||||
|
// that bailed out between the stop and the swap. Skipping them, as this
|
||||||
|
// loop used to, meant nothing in the app ever put such a project right:
|
||||||
|
// it sat at "Stopping" with the Start button disabled, permanently.
|
||||||
|
// Docker is the authority either way, so the check below is correct for
|
||||||
|
// all four.
|
||||||
|
if !matches!(
|
||||||
|
project.status,
|
||||||
|
ProjectStatus::Running
|
||||||
|
| ProjectStatus::Error
|
||||||
|
| ProjectStatus::Starting
|
||||||
|
| ProjectStatus::Stopping
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ...but never for a project this process is actively migrating: the
|
||||||
|
// container is legitimately absent for part of that run.
|
||||||
|
if crate::commands::migration_commands::is_migrating(&project.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -14,7 +15,94 @@ 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();
|
||||||
|
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]
|
||||||
@@ -67,6 +155,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();
|
||||||
@@ -115,3 +275,96 @@ 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,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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,22 @@ pub async fn create_attached_exec(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
cmd: Vec<String>,
|
cmd: Vec<String>,
|
||||||
tty: bool,
|
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> {
|
) -> Result<AttachedExec, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -49,8 +65,8 @@ pub async fn create_attached_exec(
|
|||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
tty: Some(tty),
|
tty: Some(tty),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
user: Some("claude".to_string()),
|
user: Some(user.to_string()),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some(working_dir.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -371,11 +387,96 @@ pub async fn upload_host_file_to_container(
|
|||||||
Ok(format!("/tmp/{}", dest_name))
|
Ok(format!("/tmp/{}", dest_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()?;
|
||||||
|
|
||||||
|
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(mode);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
||||||
|
/// limit is exceeded, at which point the caller must stop reading.
|
||||||
|
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
||||||
|
if buf.len() + chunk.len() > limit {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buf.push_str(chunk);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
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_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
|
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||||
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
/// 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
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
@@ -400,6 +501,32 @@ pub async fn exec_oneshot_env_status(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
cmd: Vec<String>,
|
cmd: Vec<String>,
|
||||||
env: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_oneshot_inner(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
) -> Result<(String, i64), String> {
|
) -> Result<(String, i64), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -411,7 +538,7 @@ pub async fn exec_oneshot_env_status(
|
|||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
env: if env.is_empty() { None } else { Some(env) },
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
user: Some("claude".to_string()),
|
user: Some(user.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -428,7 +555,19 @@ pub async fn exec_oneshot_env_status(
|
|||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
Ok(data) => {
|
||||||
|
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
||||||
|
if !push_capped(&mut combined, &chunk, limit) {
|
||||||
|
// Stop reading rather than truncate silently: every
|
||||||
|
// caller parses this output, and a half-read
|
||||||
|
// manifest or JSON array is worse than an error.
|
||||||
|
// Dropping `output` kills the exec's stream.
|
||||||
|
return Err(format!(
|
||||||
|
"Command output exceeded {} bytes and was abandoned",
|
||||||
|
limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,3 +602,42 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_under_the_limit_is_buffered_whole() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
assert!(push_capped(&mut buf, "hello ", 16));
|
||||||
|
assert!(push_capped(&mut buf, "world", 16));
|
||||||
|
assert_eq!(buf, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||||
|
// The abandoned chunk must not land in the buffer either: a caller that
|
||||||
|
// ignored the error would otherwise parse a half-read document.
|
||||||
|
let mut buf = String::new();
|
||||||
|
assert!(push_capped(&mut buf, "0123456789", 12));
|
||||||
|
assert!(!push_capped(&mut buf, "0123456789", 12));
|
||||||
|
assert_eq!(buf, "0123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_oversized_chunk_is_refused() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
assert!(!push_capped(&mut buf, "0123456789", 4));
|
||||||
|
assert!(buf.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +1,15 @@
|
|||||||
|
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 legacy_cleanup;
|
||||||
|
pub mod migration;
|
||||||
pub mod stt;
|
pub mod stt;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use gateway::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use stt::*;
|
pub use stt::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -17,3 +22,8 @@ pub use image::*;
|
|||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use legacy_cleanup::*;
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod auth_bridge;
|
mod auth_bridge;
|
||||||
|
mod browser_view;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
mod install_helper;
|
mod install_helper;
|
||||||
@@ -7,13 +8,17 @@ mod models;
|
|||||||
mod storage;
|
mod storage;
|
||||||
pub mod web_terminal;
|
pub mod web_terminal;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use auth_bridge::AuthBridgeManager;
|
use auth_bridge::AuthBridgeManager;
|
||||||
use docker::exec::ExecSessionManager;
|
use docker::exec::ExecSessionManager;
|
||||||
use storage::projects_store::ProjectsStore;
|
use storage::projects_store::ProjectsStore;
|
||||||
use storage::settings_store::SettingsStore;
|
use storage::settings_store::SettingsStore;
|
||||||
use tauri::Manager;
|
use tauri::async_runtime::JoinHandle;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::sync::watch;
|
||||||
use web_terminal::WebTerminalServer;
|
use web_terminal::WebTerminalServer;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -22,6 +27,161 @@ pub struct AppState {
|
|||||||
pub exec_manager: Arc<ExecSessionManager>,
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
|
pub lifecycle: Arc<Lifecycle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Startup / shutdown coordination
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Total wall-clock budget for teardown before the process exits regardless.
|
||||||
|
///
|
||||||
|
/// Six teardown steps used to run *serially* inside a `block_on` on the
|
||||||
|
/// window-event thread with no timeout: two container stops at Docker's default
|
||||||
|
/// 10s grace, a `docker exec` per browser-view project, and every bollard call
|
||||||
|
/// inheriting a 120s client timeout. Quitting after Docker Desktop had already
|
||||||
|
/// gone away froze the window for minutes. Nothing here is worth more than a
|
||||||
|
/// few seconds of a user's exit.
|
||||||
|
const SHUTDOWN_BUDGET: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
|
/// How long the in-flight auto-start tasks get to notice cancellation before
|
||||||
|
/// they are aborted. They only have to reach their next await point.
|
||||||
|
const STARTUP_CANCEL_BUDGET: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Backoff (seconds) between auto-start attempts. Docker Desktop routinely
|
||||||
|
/// takes 30-60s to accept API calls after login, which is exactly the window in
|
||||||
|
/// which Triple-C used to be launched, fail once, and stay broken for the whole
|
||||||
|
/// session.
|
||||||
|
const AUTOSTART_DELAYS: [u64; 8] = [0, 2, 4, 8, 15, 15, 30, 30];
|
||||||
|
|
||||||
|
/// Owns the "is the app going away?" signal and the handles of the background
|
||||||
|
/// tasks started during `setup`.
|
||||||
|
///
|
||||||
|
/// Both auto-starts are fire-and-forget, and quitting quickly used to race
|
||||||
|
/// them: `CloseRequested` stopped a gateway container that did not exist yet,
|
||||||
|
/// and the detached task then created and started it *after* the app was gone —
|
||||||
|
/// leaving an orphan proxy holding a provider key. The same shape orphaned the
|
||||||
|
/// web terminal, whose task wrote its server into the state slot that
|
||||||
|
/// `CloseRequested` had already `take()`-n. Shutdown therefore cancels and
|
||||||
|
/// waits for these tasks *before* running teardown, so teardown always sees the
|
||||||
|
/// final state of the world.
|
||||||
|
pub struct Lifecycle {
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
tasks: Mutex<Vec<JoinHandle<()>>>,
|
||||||
|
shutting_down: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lifecycle {
|
||||||
|
fn new() -> Self {
|
||||||
|
let (cancel, _) = watch::channel(false);
|
||||||
|
Self {
|
||||||
|
cancel,
|
||||||
|
tasks: Mutex::new(Vec::new()),
|
||||||
|
shutting_down: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A receiver that flips to `true` when the app starts shutting down.
|
||||||
|
pub fn cancellation(&self) -> watch::Receiver<bool> {
|
||||||
|
self.cancel.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_shutting_down(&self) -> bool {
|
||||||
|
*self.cancel.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a startup task so shutdown can wait for it.
|
||||||
|
fn track(&self, handle: JoinHandle<()>) {
|
||||||
|
self.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` the first time only — the window can emit `CloseRequested` again
|
||||||
|
/// once we ask the app to exit, and teardown must not restart.
|
||||||
|
fn begin_shutdown(&self) -> bool {
|
||||||
|
if self.shutting_down.swap(true, Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// `send_replace`, not `send`: `send` reports an error *and leaves the
|
||||||
|
// value untouched* when nothing is subscribed, which is exactly the
|
||||||
|
// case when neither auto-start is enabled — and `is_shutting_down` (the
|
||||||
|
// web terminal's check) reads that stored value.
|
||||||
|
self.cancel.send_replace(true);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let the tracked startup tasks unwind, then abort whatever is left.
|
||||||
|
async fn settle_startup_tasks(&self) {
|
||||||
|
let mut handles: Vec<JoinHandle<()>> = std::mem::take(
|
||||||
|
&mut *self.tasks.lock().unwrap_or_else(|e| e.into_inner()),
|
||||||
|
);
|
||||||
|
if handles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settle = async {
|
||||||
|
for handle in &mut handles {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if tokio::time::timeout(STARTUP_CANCEL_BUDGET, settle).await.is_err() {
|
||||||
|
log::warn!("Startup tasks did not settle in time — aborting them");
|
||||||
|
for handle in &handles {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an auto-start until it succeeds, the app quits, or the retries run out.
|
||||||
|
///
|
||||||
|
/// Without this a launch that beats the Docker daemon (or Docker Desktop) to
|
||||||
|
/// readiness left the gateway and STT down for the entire session, with no
|
||||||
|
/// path back: nothing re-attempts them.
|
||||||
|
async fn autostart_with_retry<F, Fut>(label: &str, mut cancel: watch::Receiver<bool>, mut attempt: F)
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<(), String>>,
|
||||||
|
{
|
||||||
|
for (index, delay) in AUTOSTART_DELAYS.iter().enumerate() {
|
||||||
|
if *delay > 0 {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => return,
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(*delay)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *cancel.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellation races the attempt itself, not just the backoff, so a
|
||||||
|
// quick quit isn't held up by an in-flight Docker call — and, more
|
||||||
|
// importantly, so the attempt cannot complete after teardown has run.
|
||||||
|
let result = tokio::select! {
|
||||||
|
_ = cancel.changed() => return,
|
||||||
|
r = attempt() => r,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
if index > 0 {
|
||||||
|
log::info!("{} auto-start succeeded on attempt {}", label, index + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let last = index + 1 == AUTOSTART_DELAYS.len();
|
||||||
|
if index == 0 {
|
||||||
|
log::warn!("{} auto-start failed ({}) — will retry", label, e);
|
||||||
|
} else if last {
|
||||||
|
log::error!("{} auto-start gave up after {} attempts: {}", label, index + 1, e);
|
||||||
|
} else {
|
||||||
|
log::debug!("{} auto-start attempt {} failed: {}", label, index + 1, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -43,11 +203,13 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||||
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
||||||
|
let lifecycle = Arc::new(Lifecycle::new());
|
||||||
|
|
||||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||||
let projects_store_setup = projects_store.clone();
|
let projects_store_setup = projects_store.clone();
|
||||||
let settings_store_setup = settings_store.clone();
|
let settings_store_setup = settings_store.clone();
|
||||||
let exec_manager_setup = exec_manager.clone();
|
let exec_manager_setup = exec_manager.clone();
|
||||||
|
let lifecycle_setup = lifecycle.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
@@ -59,6 +221,7 @@ pub fn run() {
|
|||||||
exec_manager,
|
exec_manager,
|
||||||
auth_bridge,
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
|
lifecycle,
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
@@ -83,8 +246,9 @@ pub fn run() {
|
|||||||
let set_store = settings_store_setup.clone();
|
let set_store = settings_store_setup.clone();
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
let web_server_mutex = state.web_terminal_server.clone();
|
let web_server_mutex = state.web_terminal_server.clone();
|
||||||
|
let lifecycle = lifecycle_setup.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
match WebTerminalServer::start(
|
match WebTerminalServer::start(
|
||||||
port,
|
port,
|
||||||
token,
|
token,
|
||||||
@@ -95,6 +259,16 @@ pub fn run() {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(server) => {
|
Ok(server) => {
|
||||||
|
// The app may have been asked to quit while the
|
||||||
|
// server was coming up, in which case teardown
|
||||||
|
// has already emptied this slot and would never
|
||||||
|
// look at it again. Stop it here instead of
|
||||||
|
// storing an orphan.
|
||||||
|
if lifecycle.is_shutting_down() {
|
||||||
|
server.stop();
|
||||||
|
log::info!("Web terminal stopped immediately: app is exiting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut guard = web_server_mutex.lock().await;
|
let mut guard = web_server_mutex.lock().await;
|
||||||
*guard = Some(server);
|
*guard = Some(server);
|
||||||
log::info!("Web terminal auto-started on port {}", port);
|
log::info!("Web terminal auto-started on port {}", port);
|
||||||
@@ -104,45 +278,130 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
lifecycle_setup.track(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-start STT container if enabled in settings
|
// Auto-start STT container if enabled in settings
|
||||||
if settings.stt.enabled {
|
if settings.stt.enabled {
|
||||||
let stt_settings = settings.stt.clone();
|
let stt_settings = settings.stt.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
let cancel = lifecycle_setup.cancellation();
|
||||||
match docker::stt::ensure_stt_running(&stt_settings).await {
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
Ok(status) => {
|
autostart_with_retry("STT container", cancel, || async {
|
||||||
if status.running {
|
let status = docker::stt::ensure_stt_running(&stt_settings).await?;
|
||||||
log::info!("STT container auto-started on port {}", stt_settings.port);
|
if status.running {
|
||||||
} else {
|
log::info!("STT container auto-started on port {}", stt_settings.port);
|
||||||
log::warn!("STT auto-start: container not running after ensure_stt_running");
|
Ok(())
|
||||||
}
|
} else {
|
||||||
|
Err("container not running after ensure_stt_running".to_string())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
})
|
||||||
log::error!("Failed to auto-start STT container: {}", e);
|
.await;
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
lifecycle_setup.track(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start model gateway container if enabled in settings
|
||||||
|
if settings.gateway.enabled {
|
||||||
|
let gateway_settings = settings.gateway.clone();
|
||||||
|
let cancel = lifecycle_setup.cancellation();
|
||||||
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
autostart_with_retry("Model gateway", cancel, || async {
|
||||||
|
let status =
|
||||||
|
docker::gateway::ensure_gateway_running(&gateway_settings).await?;
|
||||||
|
if status.running {
|
||||||
|
log::info!(
|
||||||
|
"Model gateway auto-started on port {}",
|
||||||
|
gateway_settings.port
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("container not running after ensure_gateway_running".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
lifecycle_setup.track(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
// This handler fires for *every* window, and what follows stops
|
||||||
|
// containers and exits the process. Only the main window means
|
||||||
|
// that. Secondary windows — the browser view's pop-out — are
|
||||||
|
// closed and reopened freely and must just close.
|
||||||
|
if window.label() != "main" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let state = window.state::<AppState>();
|
let state = window.state::<AppState>();
|
||||||
tauri::async_runtime::block_on(async {
|
let lifecycle = state.lifecycle.clone();
|
||||||
// Stop web terminal server
|
|
||||||
let mut server_guard = state.web_terminal_server.lock().await;
|
// Already shutting down: let the window close. That covers our
|
||||||
if let Some(server) = server_guard.take() {
|
// own `exit` unwinding it, and it deliberately leaves a second
|
||||||
server.stop();
|
// click on the X as a force-quit — teardown is a courtesy, not
|
||||||
|
// a hostage situation.
|
||||||
|
if !lifecycle.begin_shutdown() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exec_manager = state.exec_manager.clone();
|
||||||
|
let auth_bridge = state.auth_bridge.clone();
|
||||||
|
let web_terminal_server = state.web_terminal_server.clone();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
// Teardown talks to Docker, so it cannot be instant. Keep the
|
||||||
|
// window alive and tell the UI what is happening rather than
|
||||||
|
// blocking the event thread on it and looking hung.
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.emit("app-shutting-down", ());
|
||||||
|
|
||||||
|
let app_handle = window.app_handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let teardown = async {
|
||||||
|
// First: let the auto-starts unwind. Anything they are
|
||||||
|
// midway through creating has to exist before the stops
|
||||||
|
// below run, or it outlives the app.
|
||||||
|
lifecycle.settle_startup_tasks().await;
|
||||||
|
|
||||||
|
// Then everything else, concurrently — these touch
|
||||||
|
// different subsystems and nothing here depends on
|
||||||
|
// another's result. Serially, the two container stops
|
||||||
|
// alone were 20s of Docker's default grace period.
|
||||||
|
let web_terminal = async {
|
||||||
|
if let Some(server) = web_terminal_server.lock().await.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stop_stt = async {
|
||||||
|
if let Err(e) = docker::stt::stop_stt_container().await {
|
||||||
|
log::warn!("Failed to stop the STT container on exit: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stop_gateway = async {
|
||||||
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||||
|
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::join!(
|
||||||
|
web_terminal,
|
||||||
|
stop_stt,
|
||||||
|
stop_gateway,
|
||||||
|
exec_manager.close_all_sessions(),
|
||||||
|
auth_bridge.stop_all(),
|
||||||
|
browser_view::manager().stop_all(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if tokio::time::timeout(SHUTDOWN_BUDGET, teardown).await.is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"Shutdown exceeded {}s — exiting with teardown incomplete",
|
||||||
|
SHUTDOWN_BUDGET.as_secs()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Stop STT container
|
app_handle.exit(0);
|
||||||
let _ = docker::stt::stop_stt_container().await;
|
|
||||||
// Close all exec sessions
|
|
||||||
state.exec_manager.close_all_sessions().await;
|
|
||||||
// Release every host loopback port held by the auth bridge
|
|
||||||
state.auth_bridge.stop_all().await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -162,9 +421,31 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
commands::project_commands::reconcile_project_statuses,
|
||||||
|
// Container base-image migration
|
||||||
|
commands::migration_commands::get_container_staleness,
|
||||||
|
commands::migration_commands::migrate_project_to_base,
|
||||||
|
commands::migration_commands::confirm_migration,
|
||||||
|
commands::migration_commands::rollback_migration,
|
||||||
|
commands::migration_commands::get_migration_state,
|
||||||
// Auth bridge
|
// Auth bridge
|
||||||
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||||
commands::auth_bridge_commands::get_auth_bridge_status,
|
commands::auth_bridge_commands::get_auth_bridge_status,
|
||||||
|
// Browser view (Playwright dashboard pane)
|
||||||
|
browser_view::commands::set_browser_view_enabled,
|
||||||
|
browser_view::commands::get_browser_view_status,
|
||||||
|
browser_view::commands::check_browser_view_support,
|
||||||
|
browser_view::commands::install_browser_view_support,
|
||||||
|
browser_view::commands::install_browser_view_browser,
|
||||||
|
browser_view::commands::open_browser_view_popout,
|
||||||
|
browser_view::commands::close_browser_view_popout,
|
||||||
|
browser_view::commands::get_browser_view_popout_state,
|
||||||
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||||
|
browser_view::commands::open_page_in_container_browser,
|
||||||
|
browser_view::commands::set_container_page_viewport,
|
||||||
|
browser_view::commands::get_container_page_state,
|
||||||
|
browser_view::commands::close_container_page,
|
||||||
|
browser_view::commands::set_browser_view_match_window,
|
||||||
|
browser_view::commands::get_browser_view_match_window,
|
||||||
// Shared Claude Code auth token
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
commands::auth_token_commands::submit_claude_token_code,
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
@@ -176,6 +457,7 @@ pub fn run() {
|
|||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
commands::settings_commands::detect_aws_config,
|
commands::settings_commands::detect_aws_config,
|
||||||
|
commands::settings_commands::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
commands::settings_commands::detect_host_timezone,
|
||||||
// Terminal
|
// Terminal
|
||||||
@@ -216,6 +498,17 @@ pub fn run() {
|
|||||||
commands::stt_commands::build_stt_image,
|
commands::stt_commands::build_stt_image,
|
||||||
commands::stt_commands::pull_stt_image,
|
commands::stt_commands::pull_stt_image,
|
||||||
commands::stt_commands::transcribe_audio,
|
commands::stt_commands::transcribe_audio,
|
||||||
|
// Model gateway (LiteLLM)
|
||||||
|
commands::gateway_commands::get_gateway_status,
|
||||||
|
commands::gateway_commands::start_gateway,
|
||||||
|
commands::gateway_commands::stop_gateway,
|
||||||
|
commands::gateway_commands::check_gateway_health,
|
||||||
|
commands::gateway_commands::build_gateway_image,
|
||||||
|
commands::gateway_commands::pull_gateway_image,
|
||||||
|
commands::gateway_commands::set_gateway_api_key,
|
||||||
|
commands::gateway_commands::clear_gateway_api_key,
|
||||||
|
commands::gateway_commands::get_gateway_auth_token,
|
||||||
|
commands::gateway_commands::regenerate_gateway_auth_token,
|
||||||
// Container introspection (sessions / capabilities / scheduler)
|
// Container introspection (sessions / capabilities / scheduler)
|
||||||
commands::inspect_commands::list_claude_sessions,
|
commands::inspect_commands::list_claude_sessions,
|
||||||
commands::inspect_commands::resume_session_command,
|
commands::inspect_commands::resume_session_command,
|
||||||
@@ -233,3 +526,130 @@ pub fn run() {
|
|||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
/// Drives the retry loop under a paused clock, so the real backoff schedule
|
||||||
|
/// is exercised without waiting for it.
|
||||||
|
async fn run_autostart(
|
||||||
|
cancel: watch::Receiver<bool>,
|
||||||
|
outcomes: Vec<Result<(), String>>,
|
||||||
|
) -> usize {
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let counter = calls.clone();
|
||||||
|
let outcomes = Arc::new(Mutex::new(outcomes.into_iter()));
|
||||||
|
autostart_with_retry("test", cancel, move || {
|
||||||
|
let counter = counter.clone();
|
||||||
|
let outcomes = outcomes.clone();
|
||||||
|
async move {
|
||||||
|
counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
outcomes
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.unwrap_or(Err("still down".to_string()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
calls.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_working_autostart_runs_exactly_once() {
|
||||||
|
let (_tx, rx) = watch::channel(false);
|
||||||
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn an_autostart_that_beat_docker_to_readiness_recovers() {
|
||||||
|
// The regression: Docker not being up yet used to cost the whole
|
||||||
|
// session — gateway down, STT down, and nothing ever retried.
|
||||||
|
let (_tx, rx) = watch::channel(false);
|
||||||
|
let calls = run_autostart(
|
||||||
|
rx,
|
||||||
|
vec![
|
||||||
|
Err("daemon not running".to_string()),
|
||||||
|
Err("daemon not running".to_string()),
|
||||||
|
Ok(()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(calls, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_permanently_failing_autostart_gives_up_rather_than_looping_forever() {
|
||||||
|
let (_tx, rx) = watch::channel(false);
|
||||||
|
assert_eq!(
|
||||||
|
run_autostart(rx, vec![]).await,
|
||||||
|
AUTOSTART_DELAYS.len(),
|
||||||
|
"should attempt once per backoff step and then stop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_quick_quit_stops_the_retries_before_they_start() {
|
||||||
|
// Quitting before the first attempt must not leave a task that creates
|
||||||
|
// and starts a container after teardown has already run.
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
tx.send(true).unwrap();
|
||||||
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn cancelling_between_attempts_stops_the_retries() {
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let counter = calls.clone();
|
||||||
|
autostart_with_retry("test", rx, move || {
|
||||||
|
let counter = counter.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
|
async move {
|
||||||
|
counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
// The app starts quitting while this attempt is in flight.
|
||||||
|
let _ = tx.send(true);
|
||||||
|
Err("daemon not running".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_begins_exactly_once() {
|
||||||
|
// `CloseRequested` fires again when our own `exit(0)` unwinds the
|
||||||
|
// window; teardown must not start a second time.
|
||||||
|
let lifecycle = Lifecycle::new();
|
||||||
|
assert!(!lifecycle.is_shutting_down());
|
||||||
|
assert!(lifecycle.begin_shutdown());
|
||||||
|
assert!(lifecycle.is_shutting_down());
|
||||||
|
assert!(!lifecycle.begin_shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn beginning_shutdown_notifies_already_running_startup_tasks() {
|
||||||
|
let lifecycle = Lifecycle::new();
|
||||||
|
let mut cancel = lifecycle.cancellation();
|
||||||
|
assert!(!*cancel.borrow());
|
||||||
|
lifecycle.begin_shutdown();
|
||||||
|
assert!(cancel.changed().await.is_ok());
|
||||||
|
assert!(*cancel.borrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_startup_task_that_ignores_cancellation_is_abandoned_not_awaited() {
|
||||||
|
// The budget is what keeps a wedged auto-start from turning quit into a
|
||||||
|
// multi-minute freeze.
|
||||||
|
let lifecycle = Lifecycle::new();
|
||||||
|
lifecycle.track(tauri::async_runtime::spawn(async {
|
||||||
|
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||||
|
}));
|
||||||
|
lifecycle.begin_shutdown();
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
lifecycle.settle_startup_tasks().await;
|
||||||
|
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::gateway_settings::GatewaySettings;
|
||||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -53,6 +54,23 @@ pub struct GlobalOllamaSettings {
|
|||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_model_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -61,12 +79,22 @@ pub struct GlobalOpenAiCompatibleSettings {
|
|||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_model_id: Option<String>,
|
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)]
|
||||||
@@ -82,6 +110,8 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_ollama: GlobalOllamaSettings,
|
pub global_ollama: GlobalOllamaSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub global_llamacpp: GlobalLlamaCppSettings,
|
||||||
|
#[serde(default)]
|
||||||
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
|
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
|
||||||
#[serde(default = "default_global_instructions")]
|
#[serde(default = "default_global_instructions")]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
@@ -102,6 +132,8 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stt: SttSettings,
|
pub stt: SttSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub gateway: GatewaySettings,
|
||||||
|
#[serde(default)]
|
||||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +205,7 @@ 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,
|
||||||
@@ -180,6 +213,7 @@ impl Default for AppSettings {
|
|||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
global_ollama: GlobalOllamaSettings::default(),
|
global_ollama: GlobalOllamaSettings::default(),
|
||||||
|
global_llamacpp: GlobalLlamaCppSettings::default(),
|
||||||
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
|
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
|
||||||
global_claude_instructions: default_global_instructions(),
|
global_claude_instructions: default_global_instructions(),
|
||||||
global_custom_env_vars: Vec::new(),
|
global_custom_env_vars: Vec::new(),
|
||||||
@@ -190,6 +224,7 @@ impl Default for AppSettings {
|
|||||||
dismissed_image_digest: None,
|
dismissed_image_digest: None,
|
||||||
web_terminal: WebTerminalSettings::default(),
|
web_terminal: WebTerminalSettings::default(),
|
||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
|
gateway: GatewaySettings::default(),
|
||||||
global_claude_code_settings: None,
|
global_claude_code_settings: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod container_config;
|
pub mod container_config;
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod gateway_settings;
|
||||||
|
pub mod migration;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use gateway_settings::*;
|
||||||
|
pub use migration::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ pub struct Project {
|
|||||||
pub backend: Backend,
|
pub backend: Backend,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub ollama_config: Option<OllamaConfig>,
|
pub ollama_config: Option<OllamaConfig>,
|
||||||
|
#[serde(default, alias = "llama_cpp_config")]
|
||||||
|
pub llamacpp_config: Option<LlamaCppConfig>,
|
||||||
#[serde(alias = "litellm_config")]
|
#[serde(alias = "litellm_config")]
|
||||||
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
@@ -137,6 +139,28 @@ pub struct Project {
|
|||||||
/// because toggling it changes nothing about the container itself.
|
/// because toggling it changes nothing about the container itself.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auth_bridge_enabled: bool,
|
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.
|
||||||
|
#[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
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
@@ -158,6 +182,13 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permission_mode: Option<PermissionMode>,
|
pub permission_mode: Option<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
|
/// 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)]
|
#[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>,
|
||||||
@@ -191,8 +222,10 @@ pub enum ProjectStatus {
|
|||||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
/// - `Ollama`: Local or remote Ollama server
|
/// - `Ollama`: Local or remote Ollama server
|
||||||
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
|
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
/// - `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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum Backend {
|
pub enum Backend {
|
||||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
@@ -200,6 +233,10 @@ pub enum Backend {
|
|||||||
Anthropic,
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
Ollama,
|
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")]
|
#[serde(alias = "lite_llm", alias = "litellm")]
|
||||||
OpenAiCompatible,
|
OpenAiCompatible,
|
||||||
}
|
}
|
||||||
@@ -210,6 +247,28 @@ impl Default for Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How Bedrock authenticates with AWS.
|
/// How Bedrock authenticates with AWS.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -248,27 +307,60 @@ pub struct BedrockConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ollama configuration for a project.
|
/// Ollama configuration for a project.
|
||||||
/// Ollama exposes an Anthropic-compatible API endpoint.
|
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OllamaConfig {
|
pub struct OllamaConfig {
|
||||||
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
/// 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,
|
pub base_url: String,
|
||||||
/// Optional model override (e.g., "qwen3.5:27b")
|
/// Optional model override (e.g., "qwen3.5:27b")
|
||||||
pub model_id: Option<String>,
|
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.
|
/// OpenAI Compatible endpoint configuration for a project.
|
||||||
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
|
///
|
||||||
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OpenAiCompatibleConfig {
|
pub struct OpenAiCompatibleConfig {
|
||||||
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// API key for the OpenAI-compatible endpoint
|
/// API key for the endpoint
|
||||||
#[serde(skip_serializing, default)]
|
#[serde(skip_serializing, default)]
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
/// Optional model override
|
/// Optional model override
|
||||||
pub model_id: Option<String>,
|
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 {
|
||||||
@@ -283,15 +375,19 @@ impl Project {
|
|||||||
backend: Backend::default(),
|
backend: Backend::default(),
|
||||||
bedrock_config: None,
|
bedrock_config: None,
|
||||||
ollama_config: None,
|
ollama_config: None,
|
||||||
|
llamacpp_config: None,
|
||||||
openai_compatible_config: None,
|
openai_compatible_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: false,
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
auth_bridge_enabled: false,
|
auth_bridge_enabled: false,
|
||||||
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
use_shared_auth_token: default_use_shared_auth_token(),
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Host-side persistence for in-flight container base-image migrations.
|
||||||
|
//!
|
||||||
|
//! One JSON file per project under `<data_dir>/triple-c/migrations/`, written
|
||||||
|
//! with the same write-temp-then-rename dance as `projects.json` so a crash can
|
||||||
|
//! never leave a half-written state file. The staged verbatim payload tar lives
|
||||||
|
//! in the same directory.
|
||||||
|
//!
|
||||||
|
//! This is deliberately *not* part of `projects.json`: a migration is transient
|
||||||
|
//! and a migration record must survive independently of a project save racing
|
||||||
|
//! it. It is also the crash record — see
|
||||||
|
//! [`crate::models::MigrationState`] for the phase table.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::MigrationState;
|
||||||
|
|
||||||
|
/// `<data_dir>/triple-c/migrations`, created on demand.
|
||||||
|
pub fn migrations_dir() -> Result<PathBuf, String> {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||||
|
})?
|
||||||
|
.join("triple-c")
|
||||||
|
.join("migrations");
|
||||||
|
fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("Failed to create migrations directory: {}", e))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path(project_id: &str) -> Result<PathBuf, String> {
|
||||||
|
Ok(migrations_dir()?.join(format!("{}.json", sanitize(project_id))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host path for a project's staged verbatim payload.
|
||||||
|
pub fn staging_path(project_id: &str) -> Result<PathBuf, String> {
|
||||||
|
Ok(migrations_dir()?.join(format!("{}-payload.tar", sanitize(project_id))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||||
|
/// the write anywhere but the migrations directory.
|
||||||
|
fn sanitize(project_id: &str) -> String {
|
||||||
|
project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||||
|
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||||
|
/// blocking every future migration on a corrupt record.
|
||||||
|
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||||
|
let path = state_path(project_id)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read migration state: {}", e))?;
|
||||||
|
match serde_json::from_str::<MigrationState>(&data) {
|
||||||
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write a project's migration state.
|
||||||
|
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||||
|
let path = state_path(project_id)?;
|
||||||
|
let data = serde_json::to_string_pretty(state)
|
||||||
|
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||||
|
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project's migration state file. Missing is success.
|
||||||
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
|
let path = state_path(project_id)?;
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to remove migration state: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project's staged payload. Missing is success.
|
||||||
|
pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||||
|
let path = staging_path(project_id)?;
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to remove staged migration payload: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||||
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
|
assert_eq!(sanitize("a/b"), "a_b");
|
||||||
|
// The real shape — a UUID — must survive untouched, or state files
|
||||||
|
// would move the first time this function changed.
|
||||||
|
assert_eq!(
|
||||||
|
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||||
|
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod migration_store;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|||||||
@@ -156,3 +156,116 @@ pub fn delete_claude_oauth_token() -> Result<(), String> {
|
|||||||
);
|
);
|
||||||
token_result.and(version_result)
|
token_result.and(version_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Model gateway secrets (global, not per project)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Keychain service for the upstream provider API key (OpenAI etc.) the
|
||||||
|
/// LiteLLM gateway authenticates to the model provider with. This value is
|
||||||
|
/// written into the gateway's generated `config.yaml`, which is uploaded
|
||||||
|
/// straight into the container over the Docker API — it is never an env var,
|
||||||
|
/// never a Docker label, and is never returned to the frontend.
|
||||||
|
const GATEWAY_API_KEY_SERVICE: &str = "triple-c-gateway-provider-api-key";
|
||||||
|
|
||||||
|
/// Keychain service for the gateway's **master key** — the credential a
|
||||||
|
/// *project* presents to the gateway as `ANTHROPIC_AUTH_TOKEN`. Unlike the
|
||||||
|
/// provider key this one is minted by Triple-C and must be readable by the
|
||||||
|
/// user, since they have to paste it into a project's model config.
|
||||||
|
const GATEWAY_MASTER_KEY_SERVICE: &str = "triple-c-gateway-master-key";
|
||||||
|
|
||||||
|
/// Rotation id covering *both* gateway secrets, on the same reasoning as
|
||||||
|
/// `CLAUDE_TOKEN_VERSION_SERVICE`: container recreation is driven off Docker
|
||||||
|
/// labels, labels are world-readable via `docker inspect`, and a hash of a
|
||||||
|
/// secret is a verification oracle. This is unrelated random data that merely
|
||||||
|
/// changes whenever either secret does.
|
||||||
|
const GATEWAY_SECRET_VERSION_SERVICE: &str = "triple-c-gateway-secret-version";
|
||||||
|
|
||||||
|
/// Mint a fresh gateway rotation id. Called after either gateway secret moves.
|
||||||
|
fn bump_gateway_secret_version() -> Result<(), String> {
|
||||||
|
let version = uuid::Uuid::new_v4().to_string();
|
||||||
|
let entry = keyring::Entry::new(GATEWAY_SECRET_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(&version)
|
||||||
|
.map_err(|e| format!("Failed to store the gateway secret rotation id: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rotation id of the currently stored gateway secrets. Opaque random
|
||||||
|
/// data — safe to put in a Docker label, unlike either secret.
|
||||||
|
pub fn get_gateway_secret_version() -> Result<Option<String>, String> {
|
||||||
|
read_entry(
|
||||||
|
GATEWAY_SECRET_VERSION_SERVICE,
|
||||||
|
"the gateway secret rotation id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the provider API key, replacing any previous one. Blank input is
|
||||||
|
/// rejected rather than silently stored.
|
||||||
|
pub fn store_gateway_api_key(key: &str) -> Result<(), String> {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty gateway provider API key.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new(GATEWAY_API_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(key.trim())
|
||||||
|
.map_err(|e| format!("Failed to store the gateway provider API key: {}", e))?;
|
||||||
|
|
||||||
|
// Rotation id second: if this fails the key is still usable, and the stale
|
||||||
|
// id only costs one extra container recreation later.
|
||||||
|
bump_gateway_secret_version()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the provider API key. **Host-side only** — this is consumed when
|
||||||
|
/// rendering the gateway config and must not be handed to the frontend.
|
||||||
|
pub fn get_gateway_api_key() -> Result<Option<String>, String> {
|
||||||
|
read_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a provider API key is stored. A keychain failure is reported as
|
||||||
|
/// "no key" so the UI degrades to the unconfigured state instead of breaking.
|
||||||
|
pub fn has_gateway_api_key() -> bool {
|
||||||
|
matches!(get_gateway_api_key(), Ok(Some(k)) if !k.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the provider API key and rotate the id so a running gateway holding
|
||||||
|
/// the old key is flagged for recreation.
|
||||||
|
pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||||
|
let delete_result = delete_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key");
|
||||||
|
let version_result = bump_gateway_secret_version();
|
||||||
|
delete_result.and(version_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gateway master key, minting one on first use.
|
||||||
|
///
|
||||||
|
/// The gateway is published on a host port so project containers can reach it,
|
||||||
|
/// which means an unauthenticated gateway would be an open proxy onto the
|
||||||
|
/// user's provider account for anything that can route to the host. LiteLLM
|
||||||
|
/// only enforces auth when a master key is configured, so Triple-C always
|
||||||
|
/// configures one.
|
||||||
|
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||||
|
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||||
|
if !existing.trim().is_empty() {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
regenerate_gateway_master_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||||
|
/// previous value must be updated.
|
||||||
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||||
|
// LiteLLM requires the master key to start with `sk-`.
|
||||||
|
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(&key)
|
||||||
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||||
|
|
||||||
|
bump_gateway_secret_version()?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,51 @@
|
|||||||
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||||
.scroll-bottom-btn.visible { display: flex; }
|
.scroll-bottom-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
/* ── URL relay banner ───────────────────── */
|
||||||
|
.relay-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(94%, 620px);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.45);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.relay-banner.visible { display: flex; }
|
||||||
|
.relay-banner-text { flex: 1; min-width: 0; }
|
||||||
|
.relay-banner-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.relay-banner-url {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.relay-banner-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.relay-banner-dismiss:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
/* ── Empty State ─────────────────────────── */
|
/* ── Empty State ─────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -272,6 +317,15 @@
|
|||||||
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||||
|
<!-- URL relay: a CLI in the container asked for a browser. Tap-to-open only,
|
||||||
|
never automatic — see the OSC 7777 handler below. -->
|
||||||
|
<div class="relay-banner" id="relayBanner">
|
||||||
|
<div class="relay-banner-text">
|
||||||
|
<div class="relay-banner-label">Container asked to open a URL — tap to open here</div>
|
||||||
|
<a class="relay-banner-url" id="relayBannerLink" target="_blank" rel="noopener noreferrer"></a>
|
||||||
|
</div>
|
||||||
|
<button class="relay-banner-dismiss" id="relayBannerDismiss" aria-label="Dismiss">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input Bar for mobile/tablet -->
|
<!-- Input Bar for mobile/tablet -->
|
||||||
@@ -309,6 +363,114 @@
|
|||||||
const btnTab = document.getElementById('btnTab');
|
const btnTab = document.getElementById('btnTab');
|
||||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
|
const relayBanner = document.getElementById('relayBanner');
|
||||||
|
const relayBannerLink = document.getElementById('relayBannerLink');
|
||||||
|
const relayBannerDismiss = document.getElementById('relayBannerDismiss');
|
||||||
|
|
||||||
|
// ── URL relay (OSC 7777) ───────────────────
|
||||||
|
// `container/triple-c-open` — installed in the container as xdg-open,
|
||||||
|
// $BROWSER, sensible-browser, ... — emits ESC]7777;open;<base64(url)>BEL
|
||||||
|
// when a CLI wants a browser. The desktop app turns that into a host-browser
|
||||||
|
// open; here the only browser available is the *remote viewer's*.
|
||||||
|
//
|
||||||
|
// That is a different trust situation, so this deliberately does NOT mirror
|
||||||
|
// the desktop behaviour: nothing opens by itself. The web terminal may be
|
||||||
|
// reached from a phone on the LAN or through a tunnel, and the viewer's
|
||||||
|
// browser carries their own logged-in sessions and can reach their own
|
||||||
|
// network. We surface the request as a tap-to-open link and let the human
|
||||||
|
// decide. (A popup would be blocked without a user gesture anyway.)
|
||||||
|
// The same http/https allowlist as the desktop side applies — this file is
|
||||||
|
// standalone (embedded via include_str!) so it cannot import lib/urlRelay.ts;
|
||||||
|
// the logic is kept deliberately short and identical in behaviour.
|
||||||
|
const RELAY_OSC = 7777;
|
||||||
|
const RELAY_MAX_URL = 8192;
|
||||||
|
let relayTimes = [];
|
||||||
|
let relayLastUrl = null;
|
||||||
|
let relayLastAt = 0;
|
||||||
|
let relayHideTimer = null;
|
||||||
|
|
||||||
|
// ─── shared-url-sanitizer ─────────────────────────────────────
|
||||||
|
// THIS IS A COPY OF `sanitizeRelayUrl` IN app/src/lib/urlRelay.ts.
|
||||||
|
// It exists only because this file is embedded standalone via include_str!()
|
||||||
|
// and cannot import a module. Change one, change the other — and note that
|
||||||
|
// app/src/lib/urlRelay.embedded.test.ts reads this file, extracts the block
|
||||||
|
// between these two markers and runs it against the same table of cases as
|
||||||
|
// the TypeScript original, so a divergence fails the suite instead of
|
||||||
|
// silently shipping. Keep the markers, the function name and the arity
|
||||||
|
// intact: that test finds the code by them.
|
||||||
|
function sanitizeRelayUrl(raw) {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s || s.length > RELAY_MAX_URL) return null;
|
||||||
|
// Control characters and whitespace first: new URL() strips tabs/newlines,
|
||||||
|
// so "java\nscript:" would otherwise slip through as javascript:. Quotes
|
||||||
|
// and backticks go with them — all three are illegal in a URL, and this
|
||||||
|
// string ends up as an argument to something that may treat them as syntax.
|
||||||
|
for (const ch of s) {
|
||||||
|
const code = ch.codePointAt(0);
|
||||||
|
if (code <= 0x20 || code === 0x7f) return null;
|
||||||
|
if (code >= 0x80 && code <= 0x9f) return null;
|
||||||
|
if (ch === '"' || ch === "'" || ch === '`') return null;
|
||||||
|
if (ch.trim() === '') return null;
|
||||||
|
}
|
||||||
|
let u;
|
||||||
|
try { u = new URL(s); } catch (e) { return null; }
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||||
|
if (!u.hostname) return null;
|
||||||
|
if (u.username || u.password) return null; // origin spoofing
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
// ─── end shared-url-sanitizer ────────────────────────────────
|
||||||
|
|
||||||
|
function parseRelayOsc(data) {
|
||||||
|
if (typeof data !== 'string') return null;
|
||||||
|
const sep = data.indexOf(';');
|
||||||
|
if (sep === -1) return null;
|
||||||
|
if (data.slice(0, sep) !== 'open') return null;
|
||||||
|
const body = data.slice(sep + 1);
|
||||||
|
if (!body || body.length > RELAY_MAX_URL * 2) return null;
|
||||||
|
if (!/^[A-Za-z0-9+/]+=*$/.test(body)) return null;
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
const bin = atob(body);
|
||||||
|
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
|
||||||
|
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
} catch (e) { return null; }
|
||||||
|
return sanitizeRelayUrl(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the prompt rate so a runaway loop in the container can't bury the UI.
|
||||||
|
function relayAllowed(url) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (url === relayLastUrl && now - relayLastAt < 5000) {
|
||||||
|
relayLastAt = now;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
relayTimes = relayTimes.filter(t => now - t < 10000);
|
||||||
|
if (relayTimes.length >= 5) return false;
|
||||||
|
relayTimes.push(now);
|
||||||
|
relayLastUrl = url;
|
||||||
|
relayLastAt = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideRelayBanner() {
|
||||||
|
relayBanner.classList.remove('visible');
|
||||||
|
relayBannerLink.removeAttribute('href');
|
||||||
|
relayBannerLink.textContent = '';
|
||||||
|
clearTimeout(relayHideTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRelayBanner(url) {
|
||||||
|
relayBannerLink.href = url;
|
||||||
|
relayBannerLink.textContent = url;
|
||||||
|
relayBanner.classList.add('visible');
|
||||||
|
clearTimeout(relayHideTimer);
|
||||||
|
relayHideTimer = setTimeout(hideRelayBanner, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
relayBannerDismiss.addEventListener('click', hideRelayBanner);
|
||||||
|
relayBannerLink.addEventListener('click', () => hideRelayBanner());
|
||||||
|
|
||||||
// ── WebSocket ──────────────────────────────
|
// ── WebSocket ──────────────────────────────
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -448,6 +610,15 @@
|
|||||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// URL relay from the container (see the OSC 7777 notes above). Always
|
||||||
|
// returns true so the sequence is consumed and never painted as garbage,
|
||||||
|
// whether or not we act on it.
|
||||||
|
term.parser.registerOscHandler(RELAY_OSC, data => {
|
||||||
|
const url = parseRelayOsc(data);
|
||||||
|
if (url && relayAllowed(url)) showRelayBanner(url);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Create container div
|
// Create container div
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'terminal-container';
|
container.className = 'terminal-container';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"identifier": "com.triple-c.desktop",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico",
|
||||||
|
"icons/icon.icns",
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
@@ -38,6 +39,25 @@ export default function App() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||||
|
const [shuttingDown, setShuttingDown] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that can only be done once Docker answers. Called from the
|
||||||
|
* startup check *and* from the poller when the daemon shows up later — a
|
||||||
|
* session that launched before Docker was ready otherwise never reconciles
|
||||||
|
* container state or recovers an interrupted migration.
|
||||||
|
*/
|
||||||
|
const onDockerReady = useCallback(async () => {
|
||||||
|
checkImage();
|
||||||
|
// Reconcile project statuses against actual Docker container state,
|
||||||
|
// then refresh the project list so the UI reflects reality.
|
||||||
|
try {
|
||||||
|
setProjects(await reconcileProjectStatuses());
|
||||||
|
} catch {
|
||||||
|
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}, [checkImage, setProjects, refresh]);
|
||||||
|
|
||||||
// Single STT instance bound to the active session. The mic lives in the
|
// Single STT instance bound to the active session. The mic lives in the
|
||||||
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
|
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
|
||||||
@@ -57,18 +77,10 @@ export default function App() {
|
|||||||
let stopPolling: (() => void) | undefined;
|
let stopPolling: (() => void) | undefined;
|
||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) {
|
if (available) {
|
||||||
checkImage();
|
onDockerReady();
|
||||||
// Reconcile project statuses against actual Docker container state,
|
|
||||||
// then refresh the project list so the UI reflects reality.
|
|
||||||
reconcileProjectStatuses().then((projects) => {
|
|
||||||
setProjects(projects);
|
|
||||||
}).catch(() => {
|
|
||||||
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setShowInstallDialog(true);
|
setShowInstallDialog(true);
|
||||||
stopPolling = startDockerPolling();
|
stopPolling = startDockerPolling(onDockerReady);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
@@ -87,6 +99,23 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// The backend prevents the window closing so it can stop containers first,
|
||||||
|
// which freezes the UI for several seconds. This says why.
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
listen("app-shutting-down", () => setShuttingDown(true))
|
||||||
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
|
.catch((e) => console.error("Failed to listen for shutdown:", e));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
|
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,6 +151,21 @@ export default function App() {
|
|||||||
{showInstallDialog && (
|
{showInstallDialog && (
|
||||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
)}
|
)}
|
||||||
|
{shuttingDown && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="shutdown-overlay"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||||
|
<StatusIndicator tone="busy" label="Shutting down" className="text-sm" />
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Stopping containers before quitting. This window will close on its own.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import MainTabs from "./MainTabs";
|
||||||
|
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
|
||||||
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
|
const close = vi.fn();
|
||||||
|
|
||||||
|
const sessions: TerminalSession[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api-server",
|
||||||
|
sessionName: "claude",
|
||||||
|
sessionType: "claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api-server",
|
||||||
|
sessionName: "shell",
|
||||||
|
sessionType: "bash",
|
||||||
|
},
|
||||||
|
] as unknown as TerminalSession[];
|
||||||
|
|
||||||
|
const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
status: "running",
|
||||||
|
permission_mode: "bypass",
|
||||||
|
renamed_session_names: {},
|
||||||
|
},
|
||||||
|
] as unknown as Project[];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ sessions, close }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({ projects, update: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = homeTabKey("p1");
|
||||||
|
const S1 = terminalTabKey("s1");
|
||||||
|
const S2 = terminalTabKey("s2");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pointer event carrying a real `clientX`.
|
||||||
|
*
|
||||||
|
* jsdom implements no `PointerEvent`, so Testing Library's synthesized one has
|
||||||
|
* no coordinates — and the coordinate is the whole point here, since it decides
|
||||||
|
* which slot the drop lands in. `MouseEvent` has one, and React dispatches on
|
||||||
|
* the event's type name either way.
|
||||||
|
*/
|
||||||
|
function pointer(el: Element, type: string, clientX: number) {
|
||||||
|
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, button: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press, move past the drag threshold, and release over `endX`. */
|
||||||
|
function dragTab(el: Element, fromX: number, endX: number) {
|
||||||
|
pointer(el, "pointerdown", fromX);
|
||||||
|
pointer(el, "pointermove", endX);
|
||||||
|
pointer(el, "pointerup", endX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
|
||||||
|
function place(el: Element, left: number, width = 100) {
|
||||||
|
el.getBoundingClientRect = () =>
|
||||||
|
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay the strip out as three 100px tabs starting at x=0. */
|
||||||
|
function laidOut() {
|
||||||
|
const tabs = screen.getAllByRole("tab");
|
||||||
|
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = () => useAppState.getState().tabOrder;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [HOME, S1, S2],
|
||||||
|
activeTabKey: HOME,
|
||||||
|
activeSessionId: null,
|
||||||
|
projects,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MainTabs reordering", () => {
|
||||||
|
it("drags a tab to the front", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
// Left half of the first tab — the tab lands before it.
|
||||||
|
dragTab(tabs[2], 250, 10);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops after the tab when the pointer is past its midpoint", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[0], 50, 190);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops at the end when released past the last tab", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[0], 50, 800);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, S2, HOME]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dragging does not steal the selection", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[1], 150, 290);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S2, S1]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a drag select the tab's text", () => {
|
||||||
|
// A pointer-driven drag is still a mouse drag as far as the browser is
|
||||||
|
// concerned, so without this the label highlights blue while you move it.
|
||||||
|
// The rename field is exempt — selecting there is the whole point.
|
||||||
|
render(<MainTabs />);
|
||||||
|
for (const tab of screen.getAllByRole("tab")) {
|
||||||
|
expect(tab.className).toContain("select-none");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.doubleClick(screen.getAllByRole("tab")[1]);
|
||||||
|
expect(screen.getByLabelText("Rename tab").className).toContain("select-text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the tab itself under the cursor while dragging", () => {
|
||||||
|
// A dimmed source tab and a thin line do not read as "I am holding this
|
||||||
|
// tab" — the dragged copy is what makes the gesture legible.
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 120);
|
||||||
|
|
||||||
|
const ghost = screen.getByTestId("tab-drag-ghost");
|
||||||
|
expect(ghost).toHaveTextContent("shell (bash)");
|
||||||
|
expect(ghost).toHaveTextContent("▣");
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerup", 120);
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the project name when a home tab is dragged", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[0], "pointerdown", 50);
|
||||||
|
pointer(tabs[0], "pointermove", 250);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server");
|
||||||
|
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the dragged copy when the drag is abandoned", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons the drag on Escape, leaving the order alone", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a press that barely moves as a click, not a drag", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
// Two pixels of tremble, under the threshold.
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 252);
|
||||||
|
pointer(tabs[2], "pointerup", 252);
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(S2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not select the tab it just dropped", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[2], 250, 10);
|
||||||
|
// The browser fires a click after the pointerup that ended the drag.
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a press that starts on the close button", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
const close = screen.getByRole("button", { name: "Close shell (bash)" });
|
||||||
|
|
||||||
|
fireEvent(close, new MouseEvent("pointerdown", { bubbles: true, clientX: 290, button: 0 }));
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not drag a tab that is being renamed — that drag selects text", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
fireEvent.doubleClick(tabs[1]);
|
||||||
|
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
|
||||||
|
|
||||||
|
dragTab(screen.getAllByRole("tab")[1], 150, 10);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no drag payload that another element could receive", () => {
|
||||||
|
// An HTML5 drag would put the tab key in a DataTransfer, and releasing over
|
||||||
|
// any text field in the app would type `term:…` into it. Pointer events
|
||||||
|
// have nothing to hand over, and the tabs are not draggable at all.
|
||||||
|
render(<MainTabs />);
|
||||||
|
for (const tab of screen.getAllByRole("tab")) {
|
||||||
|
expect(tab).not.toHaveAttribute("draggable", "true");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
@@ -18,6 +18,9 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pixels of horizontal travel before a press becomes a drag rather than a click. */
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
@@ -28,22 +31,46 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
|||||||
/**
|
/**
|
||||||
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
* terminals (▣).
|
* terminals (▣).
|
||||||
|
*
|
||||||
|
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see
|
||||||
|
* `pointerProps` for why neither of the two obvious alternatives works.
|
||||||
|
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
||||||
*/
|
*/
|
||||||
export default function MainTabs() {
|
export default function MainTabs() {
|
||||||
const { sessions, close } = useTerminal();
|
const { sessions, close } = useTerminal();
|
||||||
const { projects, update } = useProjects();
|
const { projects, update } = useProjects();
|
||||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
tabOrder: s.tabOrder,
|
tabOrder: s.tabOrder,
|
||||||
activeTabKey: s.activeTabKey,
|
activeTabKey: s.activeTabKey,
|
||||||
setActiveTabKey: s.setActiveTabKey,
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
closeHomeTab: s.closeHomeTab,
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
moveTab: s.moveTab,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
/** Where the dragged tab is drawn, and how it looked when the drag started. */
|
||||||
|
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** A press that has not yet moved far enough to be a drag. */
|
||||||
|
const pending = useRef<{
|
||||||
|
key: string;
|
||||||
|
startX: number;
|
||||||
|
dragging: boolean;
|
||||||
|
offsetX: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
top: number;
|
||||||
|
} | null>(null);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -63,6 +90,21 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
}, [renamingId]);
|
}, [renamingId]);
|
||||||
|
|
||||||
|
// Escape abandons a drag — the one affordance a pointer-event drag has to
|
||||||
|
// supply for itself, since the OS is not running this one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragKey) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
pending.current = null;
|
||||||
|
setDragKey(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [dragKey]);
|
||||||
|
|
||||||
if (tabOrder.length === 0) {
|
if (tabOrder.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
@@ -135,136 +177,307 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabClass = (active: boolean) =>
|
const tabClass = (active: boolean, dragging: boolean) =>
|
||||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}`;
|
}${dragging ? " opacity-40" : ""}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||||
|
* uses — a ghost showing a different name from the tab it came from would be
|
||||||
|
* worse than no ghost.
|
||||||
|
*/
|
||||||
|
const tabLabel = (key: string): string => {
|
||||||
|
if (isHomeTab(key)) {
|
||||||
|
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||||
|
}
|
||||||
|
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||||
|
if (!session) return "";
|
||||||
|
const custom = getCustomName(session.projectId, session.id);
|
||||||
|
return custom
|
||||||
|
? `${session.projectName}: ${custom}`
|
||||||
|
: (session.sessionName ?? session.projectName) +
|
||||||
|
(session.sessionType === "bash" ? " (bash)" : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
pending.current = null;
|
||||||
|
setDragKey(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which slot the pointer is currently over, as an insertion index into
|
||||||
|
* `tabOrder`.
|
||||||
|
*
|
||||||
|
* Measured from the tabs actually on screen rather than from the event's
|
||||||
|
* target, so the answer is the same whatever the pointer happens to be over —
|
||||||
|
* including the drop marker itself, and including a `tabOrder` entry whose
|
||||||
|
* session has already gone and which therefore renders nothing.
|
||||||
|
*/
|
||||||
|
const dropIndexAt = (clientX: number): number => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return tabOrder.length;
|
||||||
|
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
|
||||||
|
}
|
||||||
|
return tabOrder.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging is done with pointer events, not HTML5 drag-and-drop.
|
||||||
|
*
|
||||||
|
* Two reasons, both load-bearing. Tauri's `dragDropEnabled` — which the
|
||||||
|
* terminal needs left on, because only the native drag-drop event carries
|
||||||
|
* dropped *file paths* — blocks HTML5 drag inside the webview on Windows, so
|
||||||
|
* an HTML5 implementation is simply dead there. And an HTML5 drag carries a
|
||||||
|
* `DataTransfer`: released over any text field in the app, the default
|
||||||
|
* handler types the payload into it.
|
||||||
|
*/
|
||||||
|
const pointerProps = (key: string, renaming: boolean) => ({
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
// Left button only, never from the close button, and never while the
|
||||||
|
// rename input is up — that drag is a text selection.
|
||||||
|
if (e.button !== 0 || renaming) return;
|
||||||
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
pending.current = {
|
||||||
|
key,
|
||||||
|
startX: e.clientX,
|
||||||
|
dragging: false,
|
||||||
|
// Where inside the tab the pointer grabbed it, so the ghost sits under
|
||||||
|
// the cursor exactly where the real tab was — the thing that makes a
|
||||||
|
// drag feel like moving an object rather than nudging a setting.
|
||||||
|
offsetX: e.clientX - rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
top: rect.top,
|
||||||
|
};
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
},
|
||||||
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = pending.current;
|
||||||
|
if (!drag) return;
|
||||||
|
// A few pixels of slop, so a click that trembles stays a click.
|
||||||
|
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
|
||||||
|
drag.dragging = true;
|
||||||
|
setDragKey(drag.key);
|
||||||
|
setDropIndex(dropIndexAt(e.clientX));
|
||||||
|
setGhost({
|
||||||
|
x: e.clientX - drag.offsetX,
|
||||||
|
y: drag.top,
|
||||||
|
label: tabLabel(drag.key),
|
||||||
|
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = pending.current;
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
if (!drag?.dragging) {
|
||||||
|
pending.current = null;
|
||||||
|
return; // a plain click: leave it to `onClick` to select the tab
|
||||||
|
}
|
||||||
|
const to = dropIndexAt(e.clientX);
|
||||||
|
const from = tabOrder.indexOf(drag.key);
|
||||||
|
// `to` is a slot in the strip as it looks *now*; `moveTab` places the tab
|
||||||
|
// after pulling it out, so every slot past its own shifts down one.
|
||||||
|
if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to);
|
||||||
|
// The click that follows this pointerup is the drag's, not a selection.
|
||||||
|
suppressClick.current = true;
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onPointerCancel: endDrag,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A drag in progress swallows the click it ends with. */
|
||||||
|
const activateTab = (key: string) => {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTabKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropMarker = (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="tab-drop-marker"
|
||||||
|
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0 pointer-events-none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTab = (key: string, index: number) => {
|
||||||
|
const active = activeTabKey === key;
|
||||||
|
|
||||||
|
if (isHomeTab(key)) {
|
||||||
|
const projectId = tabKeyId(key);
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(key)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...pointerProps(key, false)}
|
||||||
|
className={tabClass(active, dragKey === key)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||||
|
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeHomeTab(projectId);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${project.name} home tab`}
|
||||||
|
title="Close tab"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = tabKeyId(key);
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
const customName = getCustomName(session.projectId, session.id);
|
||||||
|
const baseLabel =
|
||||||
|
(session.sessionName ?? session.projectName) +
|
||||||
|
(session.sessionType === "bash" ? " (bash)" : "");
|
||||||
|
const displayLabel = customName
|
||||||
|
? `${session.projectName}: ${customName}`
|
||||||
|
: baseLabel;
|
||||||
|
const isRenaming = renamingId === session.id;
|
||||||
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(terminalTabKey(session.id))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(terminalTabKey(session.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => startRename(session.id)}
|
||||||
|
{...pointerProps(key, isRenaming)}
|
||||||
|
className={tabClass(active, dragKey === key)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameDraft}
|
||||||
|
aria-label="Rename tab"
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onBlur={() => commitRename(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") setRenamingId(null);
|
||||||
|
}}
|
||||||
|
className="max-w-[180px] px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||||
|
title={`Permission mode: ${badge.text}`}
|
||||||
|
>
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
close(session.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${displayLabel}`}
|
||||||
|
title="Close terminal"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The marker goes before the first tab that is *actually on screen* at or
|
||||||
|
// past the drop slot. Addressing it by raw index would lose it whenever a
|
||||||
|
// `tabOrder` entry renders nothing — the window between a session ending and
|
||||||
|
// the store dropping its key — leaving the drag with no visible target.
|
||||||
|
let markerPending = dragKey !== null && dropIndex !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
{tabOrder.map((key) => {
|
{tabOrder.map((key, index) => {
|
||||||
const active = activeTabKey === key;
|
const tab = renderTab(key, index);
|
||||||
|
if (!tab) return null;
|
||||||
if (isHomeTab(key)) {
|
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||||
const projectId = tabKeyId(key);
|
if (marker) markerPending = false;
|
||||||
const project = projects.find((p) => p.id === projectId);
|
|
||||||
if (!project) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={active}
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setActiveTabKey(key)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveTabKey(key);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={tabClass(active)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
|
||||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
|
||||||
{project.name}
|
|
||||||
</span>
|
|
||||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
closeHomeTab(projectId);
|
|
||||||
}}
|
|
||||||
aria-label={`Close ${project.name} home tab`}
|
|
||||||
title="Close tab"
|
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = tabKeyId(key);
|
|
||||||
const session = sessions.find((s) => s.id === sessionId);
|
|
||||||
if (!session) return null;
|
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
|
||||||
const customName = getCustomName(session.projectId, session.id);
|
|
||||||
const baseLabel =
|
|
||||||
(session.sessionName ?? session.projectName) +
|
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
const displayLabel = customName
|
|
||||||
? `${session.projectName}: ${customName}`
|
|
||||||
: baseLabel;
|
|
||||||
const isRenaming = renamingId === session.id;
|
|
||||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Fragment key={key}>
|
||||||
key={key}
|
{marker && dropMarker}
|
||||||
role="tab"
|
{tab}
|
||||||
aria-selected={active}
|
</Fragment>
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveTabKey(terminalTabKey(session.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
|
||||||
}}
|
|
||||||
onDoubleClick={() => startRename(session.id)}
|
|
||||||
className={tabClass(active)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
|
||||||
{isRenaming ? (
|
|
||||||
<input
|
|
||||||
ref={renameInputRef}
|
|
||||||
value={renameDraft}
|
|
||||||
aria-label="Rename tab"
|
|
||||||
onChange={(e) => setRenameDraft(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onBlur={() => commitRename(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
||||||
if (e.key === "Escape") setRenamingId(null);
|
|
||||||
}}
|
|
||||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
|
||||||
{displayLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{badge && (
|
|
||||||
<span
|
|
||||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
|
||||||
title={`Permission mode: ${badge.text}`}
|
|
||||||
>
|
|
||||||
{badge.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
close(session.id);
|
|
||||||
}}
|
|
||||||
aria-label={`Close ${displayLabel}`}
|
|
||||||
title="Close terminal"
|
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* The empty run after the last tab is a drop target too — it is where
|
||||||
|
the hand naturally goes to say "put it at the end". */}
|
||||||
|
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||||
|
|
||||||
|
{ghost && (
|
||||||
|
// A copy of the tab, following the pointer. Without it the only
|
||||||
|
// feedback is a dimmed source and a thin line, which reads as "some
|
||||||
|
// setting changed" rather than "I am holding this tab".
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="tab-drag-ghost"
|
||||||
|
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
left: ghost.x,
|
||||||
|
top: ghost.y,
|
||||||
|
boxShadow: "var(--shadow-overlay)",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
|
||||||
|
<span className="truncate max-w-[180px]">{ghost.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{menu && (() => {
|
{menu && (() => {
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
const hasCustom = session
|
const hasCustom = session
|
||||||
|
|||||||
@@ -43,26 +43,36 @@ export default function EnvVarsEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
|
||||||
|
carries `w-full`, and a width utility on the input itself does not beat
|
||||||
|
it — class-attribute order is not what resolves the conflict, stylesheet
|
||||||
|
order is. Sizing the key input directly left it asking for the whole row
|
||||||
|
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
|
||||||
|
leftover space, to an unusable sliver. */}
|
||||||
{vars.map((ev, i) => (
|
{vars.map((ev, i) => (
|
||||||
<div key={i} className="flex gap-2 items-center">
|
<div key={i} className="flex gap-2 items-center">
|
||||||
<input
|
<div className="w-2/5 shrink-0">
|
||||||
value={ev.key}
|
<input
|
||||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
value={ev.key}
|
||||||
onBlur={() => onSave(vars)}
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
placeholder="KEY"
|
onBlur={() => onSave(vars)}
|
||||||
aria-label={`Environment variable ${i + 1} name`}
|
placeholder="KEY"
|
||||||
disabled={disabled}
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
className={`w-2/5 ${monoInputClass}`}
|
disabled={disabled}
|
||||||
/>
|
className={monoInputClass}
|
||||||
<input
|
/>
|
||||||
value={ev.value}
|
</div>
|
||||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
<div className="flex-1 min-w-0">
|
||||||
onBlur={() => onSave(vars)}
|
<input
|
||||||
placeholder="value"
|
value={ev.value}
|
||||||
aria-label={`Environment variable ${i + 1} value`}
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
disabled={disabled}
|
onBlur={() => onSave(vars)}
|
||||||
className={`flex-1 ${monoInputClass}`}
|
placeholder="value"
|
||||||
/>
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import MigrateContainerModal from "./MigrateContainerModal";
|
||||||
|
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||||
|
import type { ContainerStaleness } from "../../lib/types";
|
||||||
|
|
||||||
|
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||||
|
async function flushFocus() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
stale: true,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: ["/usr/bin/socat"],
|
||||||
|
missing_features: ["Auth bridge tunnel (socat)", "Mission Control"],
|
||||||
|
apt_delta: ["socat", "bubblewrap"],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
unpreserved_data: [],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||||
|
return {
|
||||||
|
staleness: STALE,
|
||||||
|
probing: false,
|
||||||
|
probeSettled: true,
|
||||||
|
running: false,
|
||||||
|
recovered: false,
|
||||||
|
interrupted: null,
|
||||||
|
report: null,
|
||||||
|
log: [],
|
||||||
|
phaseMessage: null,
|
||||||
|
busy: false,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
keep: vi.fn(async () => {}),
|
||||||
|
rollback: vi.fn(async () => {}),
|
||||||
|
dismiss: vi.fn(async () => {}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderModal(
|
||||||
|
staleness: ContainerStaleness | null = STALE,
|
||||||
|
overrides: Partial<ContainerMigration> = {},
|
||||||
|
) {
|
||||||
|
const m = migration({ staleness, ...overrides });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<MigrateContainerModal
|
||||||
|
projectName="api-server"
|
||||||
|
staleness={staleness}
|
||||||
|
migration={m}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
return { m, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MigrateContainerModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-flight", () => {
|
||||||
|
it("leads with what is kept, as a statement rather than a choice", async () => {
|
||||||
|
await renderModal();
|
||||||
|
const kept = screen.getByText("Kept automatically");
|
||||||
|
expect(kept).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no signing in again/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Reassurance comes first: it is above the replay section in the DOM.
|
||||||
|
const replay = screen.getByText(/Reinstalled from the new base's repos/);
|
||||||
|
expect(kept.compareDocumentPosition(replay)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is a statement — there is no switch attached to it.
|
||||||
|
const keptSection = kept.closest("section");
|
||||||
|
expect(keptSection?.querySelector('[role="switch"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the verbatim-copy section when nothing user-authored was found", async () => {
|
||||||
|
await renderModal({ ...STALE, verbatim_paths: [] });
|
||||||
|
expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the verbatim-copy section with its paths when there are some", async () => {
|
||||||
|
await renderModal({
|
||||||
|
...STALE,
|
||||||
|
verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"],
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the apt packages and states the rollback's disk cost", async () => {
|
||||||
|
await renderModal();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Reinstalled from the new base's repos (2)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("socat")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/3.8–12.3 GB/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Rollback restores the system layer only/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the gains as the inverse of the missing features", async () => {
|
||||||
|
await renderModal();
|
||||||
|
expect(screen.getByText("You will gain")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Mission Control/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/61 packages the current base carries at a different version/i,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the three options through when the run is started", async () => {
|
||||||
|
const { m } = await renderModal({
|
||||||
|
...STALE,
|
||||||
|
verbatim_paths: ["/usr/local/bin/deploy.sh"],
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("switch", {
|
||||||
|
name: /Keep a rollback image until I confirm/i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Update container base" }),
|
||||||
|
);
|
||||||
|
expect(m.start).toHaveBeenCalledWith({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: true,
|
||||||
|
keep_rollback: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never derives copy_paths from a delta the probe may not have read", async () => {
|
||||||
|
// The regression: `copy_paths: copyPaths && verbatim.length > 0` read the
|
||||||
|
// toggle's meaning off `staleness`, which is null while the ~6 s probe
|
||||||
|
// runs. That sent `copy_paths: false` to a backend that recomputes the
|
||||||
|
// real set but honours the flag — files silently not copied, while this
|
||||||
|
// dialog said there was nothing to copy. The toggle's own value is the
|
||||||
|
// only thing that may be sent; the backend skips the step when *its* set
|
||||||
|
// comes out empty, which is the only place that knows.
|
||||||
|
const { m } = await renderModal({ ...STALE, verbatim_paths: [] });
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Update container base" }),
|
||||||
|
);
|
||||||
|
expect(m.start).toHaveBeenCalledWith({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: true,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be started until the probe has settled, and says so", async () => {
|
||||||
|
await renderModal(null, { probeSettled: false, probing: true });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Update container base" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/lists below are not complete until it finishes/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// "None found" and "not checked yet" must not be the same sentence.
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Still checking which apt packages/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Not checked yet.")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/No extra apt packages were found/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the data under /var that the update destroys and cannot restore", async () => {
|
||||||
|
await renderModal({
|
||||||
|
...STALE,
|
||||||
|
unpreserved_data: [
|
||||||
|
{ path: "/var/lib/postgresql", bytes: 41_000_000, file_count: 912 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const panel = screen.getByTestId("migration-unpreserved");
|
||||||
|
expect(panel.textContent).toMatch(/\/var\/lib\/postgresql/);
|
||||||
|
expect(panel.textContent).toMatch(/41\.0 MB in 912 files/);
|
||||||
|
expect(panel.textContent).toMatch(/reinstalling the package does not bring it back/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says plainly that /var is not carried across even when nothing is at risk", async () => {
|
||||||
|
await renderModal();
|
||||||
|
const panel = screen.getByTestId("migration-unpreserved");
|
||||||
|
expect(panel.textContent).toMatch(/nothing here to lose/i);
|
||||||
|
expect(panel.textContent).toMatch(/Data written under \/var is not carried across/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Resume rather than Keep on a container that is mid-swap", async () => {
|
||||||
|
// Keep drops the rollback image, and on an unfinished migration
|
||||||
|
// `:latest` still points at the old lineage — so Keep here deletes the
|
||||||
|
// only way back from a container the app can no longer reason about.
|
||||||
|
const { m } = await renderModal(STALE, {
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-20260809-100000",
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: true, keep_rollback: true },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
phase: "failed",
|
||||||
|
packages_requested: [],
|
||||||
|
packages_installed: [],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "saving it failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||||
|
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start anything on cancel", async () => {
|
||||||
|
const { m, onClose } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(m.start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mid-run", () => {
|
||||||
|
const RUNNING: Partial<ContainerMigration> = {
|
||||||
|
running: true,
|
||||||
|
log: ["Snapshotting container…", "Creating container on the new base…"],
|
||||||
|
phaseMessage: "Creating container on the new base…",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("streams the phase message and the output", async () => {
|
||||||
|
await renderModal(STALE, RUNNING);
|
||||||
|
expect(screen.getByRole("status").textContent).toBe(
|
||||||
|
"Creating container on the new base…",
|
||||||
|
);
|
||||||
|
const log = screen.getByTestId("migration-log");
|
||||||
|
expect(log.textContent).toContain("Snapshotting container…");
|
||||||
|
expect(log.textContent).toContain("Creating container on the new base…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be dismissed without cancelling the run", async () => {
|
||||||
|
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||||
|
// A run takes minutes; blocking the app for it would be wrong, so the
|
||||||
|
// dialog closes and the work carries on.
|
||||||
|
expect(
|
||||||
|
screen.getByText(/keeps running if you close it/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Nothing on the migration was touched — closing is not cancelling.
|
||||||
|
expect(m.start).not.toHaveBeenCalled();
|
||||||
|
expect(m.rollback).not.toHaveBeenCalled();
|
||||||
|
expect(m.dismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still closes on Escape and on the header ✕ while running", async () => {
|
||||||
|
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
expect(m.dismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outcome", () => {
|
||||||
|
it("shows the report in place of the pre-flight once it lands", async () => {
|
||||||
|
await renderModal(STALE, {
|
||||||
|
report: {
|
||||||
|
phase: "partial",
|
||||||
|
packages_requested: ["socat", "bubblewrap"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [
|
||||||
|
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||||
|
],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { ContainerStaleness, MigrationOptions } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
import { SwitchRow } from "../ui/Field";
|
||||||
|
import MigrationReportCard from "./MigrationReportCard";
|
||||||
|
import MigrationInterruptedCard from "./MigrationInterruptedCard";
|
||||||
|
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||||
|
import {
|
||||||
|
DATA_NOT_CARRIED,
|
||||||
|
KEPT_AUTOMATICALLY,
|
||||||
|
KEPT_WHY,
|
||||||
|
LOST_WITHOUT_REPLAY,
|
||||||
|
MID_RUN_SAFETY,
|
||||||
|
REPLAY_COST,
|
||||||
|
ROLLBACK_DISK_COST,
|
||||||
|
ROLLBACK_SCOPE,
|
||||||
|
formatDataSize,
|
||||||
|
formatSnapshotDate,
|
||||||
|
} from "./migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
staleness: ContainerStaleness | null;
|
||||||
|
migration: ContainerMigration;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
control?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
|
||||||
|
{control ? (
|
||||||
|
<SwitchRow label={title} control={control} />
|
||||||
|
) : (
|
||||||
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 space-y-1.5">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className={`text-xs leading-snug text-[var(--text-secondary)] ${
|
||||||
|
mono ? "font-mono break-all" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight, progress and outcome for a base-image migration, in one dialog.
|
||||||
|
*
|
||||||
|
* Order matters here. The reassurance comes first — almost nothing painful is
|
||||||
|
* at risk, because the two volumes re-attach untouched — and only then the
|
||||||
|
* short list of things that genuinely have to be put back. Leading with the
|
||||||
|
* options would read as "pick which of your data to lose".
|
||||||
|
*
|
||||||
|
* Once the run starts the dialog stays **dismissible**: this takes minutes, and
|
||||||
|
* a modal that blocks the whole app for the duration is worse than no progress
|
||||||
|
* UI at all. Closing it hides a view; the work and its log live in the hook.
|
||||||
|
*/
|
||||||
|
export default function MigrateContainerModal({
|
||||||
|
projectName,
|
||||||
|
staleness,
|
||||||
|
migration,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [replayPackages, setReplayPackages] = useState(true);
|
||||||
|
const [copyPaths, setCopyPaths] = useState(true);
|
||||||
|
const [keepRollback, setKeepRollback] = useState(true);
|
||||||
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { running, report, interrupted, log, phaseMessage, busy, probeSettled } =
|
||||||
|
migration;
|
||||||
|
const aptDelta = staleness?.apt_delta ?? [];
|
||||||
|
const npmDelta = staleness?.npm_global_delta ?? [];
|
||||||
|
const verbatim = staleness?.verbatim_paths ?? [];
|
||||||
|
const atRisk = staleness?.unpreserved_data ?? [];
|
||||||
|
const gains = staleness?.missing_features ?? [];
|
||||||
|
const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null);
|
||||||
|
|
||||||
|
// Follow the tail of the apt output, the way a terminal would.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = logRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [log.length]);
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
const options: MigrationOptions = {
|
||||||
|
// Deliberately *not* `&& verbatim.length > 0`. That looked like a
|
||||||
|
// harmless optimisation but read the toggle's meaning off a probe that
|
||||||
|
// may not have landed, so a null `staleness` sent `copy_paths: false`
|
||||||
|
// and the backend — which recomputes the real set but honours the flag —
|
||||||
|
// skipped files that did exist. The backend already skips the step when
|
||||||
|
// its own set comes out empty; that is the only place that knows.
|
||||||
|
replay_packages: replayPackages,
|
||||||
|
copy_paths: copyPaths,
|
||||||
|
keep_rollback: keepRollback,
|
||||||
|
};
|
||||||
|
void migration.start(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Unfinished ---------------------------------------------------------
|
||||||
|
// Ahead of the report, for the reason spelled out in MigrationInterruptedCard:
|
||||||
|
// Keep is not a legitimate action on a container that is mid-swap.
|
||||||
|
if (interrupted) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Update container base — ${projectName}`}
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MigrationInterruptedCard
|
||||||
|
record={interrupted}
|
||||||
|
busy={busy || running}
|
||||||
|
onResume={() => void migration.resume()}
|
||||||
|
onRollback={() => void migration.rollback().then(onClose)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Outcome ------------------------------------------------------------
|
||||||
|
if (report) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Update container base — ${projectName}`}
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MigrationReportCard
|
||||||
|
report={report}
|
||||||
|
busy={busy}
|
||||||
|
onKeep={() => void migration.keep().then(onClose)}
|
||||||
|
onRollback={() => void migration.rollback().then(onClose)}
|
||||||
|
onDismiss={() => void migration.dismiss().then(onClose)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Progress -----------------------------------------------------------
|
||||||
|
if (running) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Updating container base — ${projectName}`}
|
||||||
|
description="This keeps running if you close it. You can carry on using the app."
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Hide
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-[13px] text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{phaseMessage ?? "Starting…"}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
ref={logRef}
|
||||||
|
data-testid="migration-log"
|
||||||
|
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
|
||||||
|
>
|
||||||
|
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{MID_RUN_SAFETY}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Pre-flight ---------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Update container base — ${projectName}`}
|
||||||
|
description={
|
||||||
|
snapshot
|
||||||
|
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
|
||||||
|
: "Rebuilds this container on the current base image."
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[36rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!probeSettled}
|
||||||
|
onClick={start}
|
||||||
|
>
|
||||||
|
Update container base
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 0. Until the probe lands, every list below is "not known" wearing
|
||||||
|
"empty"'s clothes. Say which one it is, and do not let the run
|
||||||
|
start on an unread delta. */}
|
||||||
|
{!probeSettled && (
|
||||||
|
<section
|
||||||
|
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-[var(--text-primary)] leading-snug">
|
||||||
|
Still working out what this container has that the current base
|
||||||
|
does not. The lists below are not complete until it finishes, so
|
||||||
|
the update cannot start yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
|
||||||
|
<Section title="Kept automatically">
|
||||||
|
<BulletList items={KEPT_AUTOMATICALLY} />
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{LOST_WITHOUT_REPLAY}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 1b. The one thing that is genuinely destroyed. Directly under the
|
||||||
|
reassurance, because a user who reads only the top of this dialog
|
||||||
|
must not come away thinking nothing is at stake. */}
|
||||||
|
<section
|
||||||
|
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
|
||||||
|
data-testid="migration-unpreserved"
|
||||||
|
>
|
||||||
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
{atRisk.length > 0
|
||||||
|
? `Destroyed, and not restored by this update (${atRisk.length})`
|
||||||
|
: "Not carried across"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{DATA_NOT_CARRIED}
|
||||||
|
</p>
|
||||||
|
{probeSettled ? (
|
||||||
|
atRisk.length > 0 ? (
|
||||||
|
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
|
||||||
|
{atRisk.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.path}
|
||||||
|
className="text-xs leading-snug text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<span className="font-mono break-all">{d.path}</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{" "}
|
||||||
|
— {formatDataSize(d.bytes)} in {d.file_count} file
|
||||||
|
{d.file_count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Nothing was found under <code className="font-mono">/var</code>{" "}
|
||||||
|
on this container, so there is nothing here to lose.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Not checked yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. The apt replay. */}
|
||||||
|
<Section
|
||||||
|
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Reinstall system packages from the new base's repositories"
|
||||||
|
checked={replayPackages}
|
||||||
|
onChange={setReplayPackages}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{aptDelta.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{/* "None found" and "not looked yet" are different sentences.
|
||||||
|
Printing the first while the probe is still running is how a
|
||||||
|
user ends up believing a delta was empty when it was unread. */}
|
||||||
|
{probeSettled
|
||||||
|
? "No extra apt packages were found on this container."
|
||||||
|
: "Still checking which apt packages this container added."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<BulletList items={aptDelta} mono />
|
||||||
|
)}
|
||||||
|
{npmDelta.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] pt-1">
|
||||||
|
Global npm packages ({npmDelta.length}):
|
||||||
|
</p>
|
||||||
|
<BulletList items={npmDelta} mono />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 3. Verbatim copies — usually nothing once the probe has settled, so
|
||||||
|
usually not shown at all. Shown while it has not, because a hidden
|
||||||
|
section reads as "there is nothing here". */}
|
||||||
|
{(verbatim.length > 0 || !probeSettled) && (
|
||||||
|
<Section
|
||||||
|
title={
|
||||||
|
probeSettled
|
||||||
|
? `Copied across as-is (${verbatim.length})`
|
||||||
|
: "Copied across as-is"
|
||||||
|
}
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Copy user-authored files across as-is"
|
||||||
|
checked={copyPaths}
|
||||||
|
onChange={setCopyPaths}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Content under <code className="font-mono">/usr/local</code>,{" "}
|
||||||
|
<code className="font-mono">/opt</code>,{" "}
|
||||||
|
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
|
||||||
|
<code className="font-mono">/workspace</code> that belongs to no
|
||||||
|
package, so it cannot be reinstalled from a repository.
|
||||||
|
</p>
|
||||||
|
{probeSettled ? (
|
||||||
|
<BulletList items={verbatim} mono />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Still checking what is there.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. The rollback image, with its real disk cost stated. */}
|
||||||
|
<Section
|
||||||
|
title="Keep a rollback image until I confirm"
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Keep a rollback image until I confirm"
|
||||||
|
checked={keepRollback}
|
||||||
|
onChange={setKeepRollback}
|
||||||
|
tone="caution"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_DISK_COST}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_SCOPE}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{gains.length > 0 && (
|
||||||
|
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
|
||||||
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
You will gain
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{gains.map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature}
|
||||||
|
className="text-xs leading-snug text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--success)]">
|
||||||
|
+{" "}
|
||||||
|
</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{/* "A different version", not "behind" — the count measures drift
|
||||||
|
from the base, not a guarantee that each one is an upgrade. */}
|
||||||
|
{(staleness?.outdated_package_count ?? 0) > 0 && (
|
||||||
|
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
Plus {staleness?.outdated_package_count} package
|
||||||
|
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
|
||||||
|
base carries at a different version, security updates among them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { MigrationState } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import StatusIndicator from "../ui/StatusIndicator";
|
||||||
|
import { ROLLBACK_SCOPE, formatSnapshotDate } from "./migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: MigrationState;
|
||||||
|
/** Disables the action row while resume/rollback is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
onResume: () => void;
|
||||||
|
onRollback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A migration that got past the container swap and stopped there.
|
||||||
|
*
|
||||||
|
* This is deliberately **not** [`MigrationReportCard`]. That card's primary
|
||||||
|
* action is Keep, which means "accept this and drop the rollback image" — and
|
||||||
|
* on an unfinished migration `triple-c-snapshot-<id>:latest` still points at
|
||||||
|
* the *old* lineage, so Keep would delete the only way back while leaving a
|
||||||
|
* container the app can no longer reason about. The backend's own message on
|
||||||
|
* this record says to resume; offering Keep beside it was the UI contradicting
|
||||||
|
* the backend and losing.
|
||||||
|
*
|
||||||
|
* So the two actions here are Resume and Roll back, and nothing else. It is
|
||||||
|
* shown ahead of any report, whether the record was found on mount or produced
|
||||||
|
* by a run that just failed — those are the same situation.
|
||||||
|
*/
|
||||||
|
export default function MigrationInterruptedCard({
|
||||||
|
record,
|
||||||
|
busy = false,
|
||||||
|
onResume,
|
||||||
|
onRollback,
|
||||||
|
}: Props) {
|
||||||
|
const started = formatSnapshotDate(record.started_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<StatusIndicator
|
||||||
|
tone="error"
|
||||||
|
label="The container base update did not finish"
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
This container is part-way onto the new base: it was replaced, but the
|
||||||
|
result was never saved
|
||||||
|
{started ? `. The update started ${started}` : ""}. Resuming replays the
|
||||||
|
same plan it was given — it is the only way to finish it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{record.report?.message && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug select-text">
|
||||||
|
{record.report.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_SCOPE}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||||
|
<Button size="md" variant="primary" disabled={busy} onClick={onResume}>
|
||||||
|
Resume update
|
||||||
|
</Button>
|
||||||
|
{record.rollback_image && (
|
||||||
|
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||||
|
Roll back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { MigrationReport } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import StatusIndicator from "../ui/StatusIndicator";
|
||||||
|
import {
|
||||||
|
ROLLBACK_SCOPE,
|
||||||
|
aptRetryCommand,
|
||||||
|
failureReportText,
|
||||||
|
} from "./migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
report: MigrationReport;
|
||||||
|
/** Disables the action row while confirm/rollback is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
onKeep: () => void;
|
||||||
|
onRollback: () => void;
|
||||||
|
/** Only offered when there is nothing to keep or roll back. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of a migration, rendered identically in the Overview banner and
|
||||||
|
* in the modal so a user who closed the modal is not shown a different story.
|
||||||
|
*
|
||||||
|
* A **partial** is the case this component exists for. The user arrived here
|
||||||
|
* because containers degrade silently — a run that quietly dropped `socat` and
|
||||||
|
* called itself a success would be exactly the same bug in a new place. So a
|
||||||
|
* partial is painted as a warning, names every package and the reason it
|
||||||
|
* failed, and hands over the literal `apt-get` line to finish the job.
|
||||||
|
*/
|
||||||
|
export default function MigrationReportCard({
|
||||||
|
report,
|
||||||
|
busy = false,
|
||||||
|
onKeep,
|
||||||
|
onRollback,
|
||||||
|
onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const [copied, setCopied] = useState<"command" | "detail" | null>(null);
|
||||||
|
const partial = report.phase === "partial";
|
||||||
|
const failed = report.phase === "failed";
|
||||||
|
const rolledBack = report.phase === "rolled_back";
|
||||||
|
|
||||||
|
const copy = async (what: "command" | "detail", text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(what);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard can be denied; the text is selectable on screen either way.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Partial and failed are painted as failures. A partial that reads as a
|
||||||
|
// success is precisely how a container ends up silently degraded.
|
||||||
|
const tone = partial || failed ? "error" : rolledBack ? "off" : "ok";
|
||||||
|
const heading = partial
|
||||||
|
? "Updated, but not completely"
|
||||||
|
: failed
|
||||||
|
? "Update failed"
|
||||||
|
: rolledBack
|
||||||
|
? "Rolled back"
|
||||||
|
: "Container base updated";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<StatusIndicator tone={tone} label={heading} className="text-[13px] font-semibold" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.phase === "succeeded" && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.packages_installed.length} package
|
||||||
|
{report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "}
|
||||||
|
{report.features_restored.length} feature
|
||||||
|
{report.features_restored.length === 1 ? "" : "s"} restored.
|
||||||
|
{report.paths_copied.length > 0
|
||||||
|
? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.message ||
|
||||||
|
"Update failed. Your container has been restored to its previous state."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rolledBack && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.message || "The previous system layer has been put back."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{partial && (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<p className="text-[13px] text-[var(--text-primary)]">
|
||||||
|
{report.packages_installed.length} of{" "}
|
||||||
|
{report.packages_requested.length} packages went back on.{" "}
|
||||||
|
<strong>
|
||||||
|
{report.packages_failed.length} did not
|
||||||
|
</strong>
|
||||||
|
, so this container is still missing something it had before.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] px-3 py-2 select-text"
|
||||||
|
data-testid="migration-failures"
|
||||||
|
>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{report.packages_failed.map((failure) => (
|
||||||
|
<li key={failure.name} className="text-xs leading-snug">
|
||||||
|
<span className="font-mono font-semibold text-[var(--text-primary)]">
|
||||||
|
{failure.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]"> — {failure.reason}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.packages_failed.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Finish by hand in a shell inside the container:
|
||||||
|
</p>
|
||||||
|
<code className="block px-2.5 py-1.5 font-mono text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] overflow-x-auto whitespace-pre select-text">
|
||||||
|
{aptRetryCommand(report.packages_failed)}
|
||||||
|
</code>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
copy("command", aptRetryCommand(report.packages_failed))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied === "command" ? "Copied ✓" : "Copy apt-get line"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
copy("detail", failureReportText(report.packages_failed))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied === "detail" ? "Copied ✓" : "Copy failure details"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.features_restored.length > 0 && !failed && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Restored
|
||||||
|
</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{report.features_restored.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.message && !failed && !rolledBack && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] select-text">{report.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.rollback_available && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_SCOPE}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
|
{report.rollback_available ? (
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="primary" disabled={busy} onClick={onKeep}>
|
||||||
|
Keep
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||||
|
Roll back
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="md" disabled={busy} onClick={onDismiss}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import AutomationTab from "./AutomationTab";
|
||||||
|
import type { Project, ScheduledTask } from "../../../lib/types";
|
||||||
|
|
||||||
|
const listScheduledTasks = vi.fn(async () => tasks);
|
||||||
|
const getSchedulerNotifications = vi.fn(async () => []);
|
||||||
|
const runScheduledTaskNow = vi.fn(async () => "started");
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
|
listScheduledTasks: () => listScheduledTasks(),
|
||||||
|
getSchedulerNotifications: () => getSchedulerNotifications(),
|
||||||
|
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
|
||||||
|
clearSchedulerNotifications: vi.fn(async () => {}),
|
||||||
|
getScheduledTaskLog: vi.fn(async () => ""),
|
||||||
|
removeScheduledTask: vi.fn(async () => {}),
|
||||||
|
setScheduledTaskEnabled: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../store/appState", () => ({
|
||||||
|
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||||
|
|
||||||
|
const baseTask: ScheduledTask = {
|
||||||
|
id: "a1b2c3d4",
|
||||||
|
name: "nightly",
|
||||||
|
prompt: "Run the suite",
|
||||||
|
schedule: "0 3 * * *",
|
||||||
|
task_type: "recurring",
|
||||||
|
at: null,
|
||||||
|
enabled: true,
|
||||||
|
working_dir: "/workspace",
|
||||||
|
created_at: null,
|
||||||
|
last_run: null,
|
||||||
|
next_run: null,
|
||||||
|
running: false,
|
||||||
|
running_since: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasks: ScheduledTask[] = [];
|
||||||
|
|
||||||
|
async function renderTab() {
|
||||||
|
render(<AutomationTab project={project} />);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
tasks = [baseTask];
|
||||||
|
listScheduledTasks.mockClear();
|
||||||
|
runScheduledTaskNow.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AutomationTab run state", () => {
|
||||||
|
it("offers Run now for an idle task and says nothing about running", async () => {
|
||||||
|
await renderTab();
|
||||||
|
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
|
||||||
|
expect(screen.queryByText(/Running/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
|
||||||
|
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
|
||||||
|
await renderTab();
|
||||||
|
|
||||||
|
// The whole point: a detached run is visible rather than silent.
|
||||||
|
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
|
||||||
|
await renderTab();
|
||||||
|
const callsAfterLoad = listScheduledTasks.mock.calls.length;
|
||||||
|
|
||||||
|
// The runner needs a moment to write its state file; until then the task
|
||||||
|
// still reads as idle, which is exactly the window that used to look dead.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
|
||||||
|
|
||||||
|
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
|
||||||
|
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops polling once nothing is running", async () => {
|
||||||
|
await renderTab();
|
||||||
|
// No trigger, nothing running: the interval must not be armed at all.
|
||||||
|
const before = listScheduledTasks.mock.calls.length;
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(listScheduledTasks.mock.calls.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle";
|
|||||||
import Modal from "../../ui/Modal";
|
import Modal from "../../ui/Modal";
|
||||||
import StatusIndicator from "../../ui/StatusIndicator";
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
import TaskEditorModal from "./TaskEditorModal";
|
import TaskEditorModal from "./TaskEditorModal";
|
||||||
import { formatAge } from "./format";
|
import { formatAge, formatRunningFor } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
|
|||||||
|
|
||||||
useEffect(load, [load]);
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
// A task in flight is the one state this view cannot sit still for: runs are
|
||||||
|
// detached, so without polling "Run now" looks like it did nothing until the
|
||||||
|
// user reaches for Refresh. Polling stops as soon as nothing is running.
|
||||||
|
//
|
||||||
|
// `justTriggered` covers the gap between firing a run and the runner writing
|
||||||
|
// its state file — a second or two in which the task still reads as idle, and
|
||||||
|
// where giving up on polling would reproduce the exact silence this fixes.
|
||||||
|
const anyTaskRunning = tasks.some((t) => t.running);
|
||||||
|
const [justTriggered, setJustTriggered] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
|
||||||
|
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [running, anyTaskRunning, justTriggered, load]);
|
||||||
|
|
||||||
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||||
setBusyTaskId(taskId);
|
setBusyTaskId(taskId);
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
|
|||||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||||
{task.task_type}
|
{task.task_type}
|
||||||
</span>
|
</span>
|
||||||
|
{task.running && (
|
||||||
|
<StatusIndicator
|
||||||
|
tone="busy"
|
||||||
|
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||||
{task.at ?? task.schedule}
|
{task.at ?? task.schedule}
|
||||||
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
disabled={busyTaskId === task.id}
|
disabled={busyTaskId === task.id || task.running}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
withTask(task.id, "Run now", () =>
|
withTask(task.id, "Run now", async () => {
|
||||||
runScheduledTaskNow(project.id, task.id),
|
await runScheduledTaskNow(project.id, task.id);
|
||||||
)
|
setJustTriggered(Date.now());
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Run now
|
{task.running ? "Running…" : "Run now"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import BrowserTab from "./BrowserTab";
|
||||||
|
import type {
|
||||||
|
BrowserSetupOutcome,
|
||||||
|
BrowserViewStatus,
|
||||||
|
PlaywrightDetection,
|
||||||
|
Project,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
|
||||||
|
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||||
|
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||||
|
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
|
||||||
|
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
|
||||||
|
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
||||||
|
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||||
|
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||||
|
const getBrowserViewPopoutState =
|
||||||
|
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||||
|
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||||
|
const openPageInContainerBrowser =
|
||||||
|
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
|
||||||
|
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
|
||||||
|
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
const setContainerProgress = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
|
getBrowserViewStatus: () => getBrowserViewStatus(),
|
||||||
|
setBrowserViewEnabled: () => setBrowserViewEnabled(),
|
||||||
|
checkBrowserViewSupport: () => checkBrowserViewSupport(),
|
||||||
|
installBrowserViewSupport: () => installBrowserViewSupport(),
|
||||||
|
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||||
|
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||||
|
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||||
|
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||||
|
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||||
|
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||||
|
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
|
||||||
|
openPageInContainerBrowser(id, url, w, h),
|
||||||
|
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
|
||||||
|
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async () => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
pushToast,
|
||||||
|
setContainerProgress,
|
||||||
|
containerProgress: {} as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../../store/appState", () => ({
|
||||||
|
useAppState: (selector: (s: unknown) => unknown) => selector(storeState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const OFF: BrowserViewStatus = {
|
||||||
|
enabled: false,
|
||||||
|
state: "off",
|
||||||
|
url: null,
|
||||||
|
host_port: null,
|
||||||
|
container_port: null,
|
||||||
|
started_at: null,
|
||||||
|
detection: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTHING: PlaywrightDetection = {
|
||||||
|
node_version: "22.11.0",
|
||||||
|
playwright_version: null,
|
||||||
|
playwright_path: null,
|
||||||
|
playwright_cli: null,
|
||||||
|
has_bind: false,
|
||||||
|
cli_version: null,
|
||||||
|
cli_entry: null,
|
||||||
|
browsers: [],
|
||||||
|
chrome_channel: null,
|
||||||
|
chromium_executable: null,
|
||||||
|
chromium_executable_exists: false,
|
||||||
|
script_playwright_version: null,
|
||||||
|
script_chromium_executable: null,
|
||||||
|
script_chromium_executable_exists: false,
|
||||||
|
searched: [
|
||||||
|
"/workspace",
|
||||||
|
"/usr/lib/node_modules",
|
||||||
|
"/home/claude/.npm/_npx/9f3a/node_modules",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const READY: PlaywrightDetection = {
|
||||||
|
...NOTHING,
|
||||||
|
playwright_version: "1.62.1",
|
||||||
|
playwright_path: "/workspace/node_modules/playwright-core/package.json",
|
||||||
|
playwright_cli: "/workspace/node_modules/playwright-core/cli.js",
|
||||||
|
has_bind: true,
|
||||||
|
cli_version: "0.1.18",
|
||||||
|
cli_entry: "/workspace/node_modules/@playwright/cli/playwright-cli.js",
|
||||||
|
};
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
|
||||||
|
container_id: "c1",
|
||||||
|
status: "running",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: "bypass",
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
storeState.containerProgress = {};
|
||||||
|
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||||
|
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
|
||||||
|
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
|
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
|
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||||
|
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||||
|
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||||
|
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
const LIVE: BrowserViewStatus = {
|
||||||
|
...OFF,
|
||||||
|
enabled: true,
|
||||||
|
state: "running",
|
||||||
|
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||||
|
host_port: 47820,
|
||||||
|
container_port: 39321,
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Render with the view already live, which is the only state that pops out. */
|
||||||
|
async function renderLive() {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
setBrowserViewEnabled.mockResolvedValue(LIVE);
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||||
|
});
|
||||||
|
await screen.findByTitle("Playwright browser view for api-server");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BrowserTab", () => {
|
||||||
|
it("does not offer to start anything while the container is stopped", async () => {
|
||||||
|
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||||||
|
expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
|
||||||
|
expect(getBrowserViewStatus).not.toHaveBeenCalled();
|
||||||
|
expect(checkBrowserViewSupport).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts off, and never starts a view or installs anything without being asked", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
|
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
|
||||||
|
// Probing is read-only and expected; installing is a mutation and is not.
|
||||||
|
await waitFor(() => expect(checkBrowserViewSupport).toHaveBeenCalled());
|
||||||
|
expect(installBrowserViewSupport).not.toHaveBeenCalled();
|
||||||
|
expect(installBrowserViewBrowser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the live pane, pointed at loopback with a token, once started", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
setBrowserViewEnabled.mockResolvedValue({
|
||||||
|
...OFF,
|
||||||
|
enabled: true,
|
||||||
|
state: "running",
|
||||||
|
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||||
|
host_port: 47820,
|
||||||
|
container_port: 39321,
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||||||
|
expect(frame).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/127\.0\.0\.1:47820 → container :39321/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers setup before the user hits a wall, naming what is missing", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(NOTHING);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
// No Start attempt was needed to learn this.
|
||||||
|
expect(await screen.findByRole("button", { name: /set up playwright/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Missing: playwright, @playwright\/cli/)).toBeInTheDocument();
|
||||||
|
// The npx cache is shown among the searched roots — that is where an
|
||||||
|
// MCP-installed Playwright actually lives.
|
||||||
|
expect(screen.getByText(/_npx\/9f3a\/node_modules/)).toBeInTheDocument();
|
||||||
|
// A browser can't be installed before Playwright is.
|
||||||
|
expect(screen.getByRole("button", { name: /install chromium/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs Playwright on request and updates itself from the fresh probe", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(NOTHING);
|
||||||
|
installBrowserViewSupport.mockResolvedValue({
|
||||||
|
detection: READY,
|
||||||
|
log: "added 5 packages in 3s",
|
||||||
|
browser_launched: null,
|
||||||
|
warning: "Playwright is installed, but this container has no browser to drive yet.",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
const button = await screen.findByRole("button", { name: /set up playwright/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(installBrowserViewSupport).toHaveBeenCalled());
|
||||||
|
// The pane re-rendered from the returned probe — no reopening the tab.
|
||||||
|
expect(await screen.findByText("1.62.1")).toBeInTheDocument();
|
||||||
|
// Stated in the warning box, and again in the pane's own summary line.
|
||||||
|
expect(screen.getAllByText(/no browser to drive yet/).length).toBeGreaterThan(0);
|
||||||
|
// And the browser buttons are now live.
|
||||||
|
expect(screen.getByRole("button", { name: /install chromium/i })).toBeEnabled();
|
||||||
|
expect(screen.getByRole("button", { name: /install chrome channel/i })).toBeEnabled();
|
||||||
|
// The progress line is always cleared, whatever happened.
|
||||||
|
expect(setContainerProgress).toHaveBeenCalledWith("p1", null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says which browser is for which caller, and states the size first", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/several hundred mb/i)).toBeInTheDocument();
|
||||||
|
// The copy is broken across a <code> element, so match the container.
|
||||||
|
expect(
|
||||||
|
screen.getByText((_, el) =>
|
||||||
|
(el?.textContent ?? "").includes("@playwright/mcp") &&
|
||||||
|
(el?.textContent ?? "").includes("asks for") &&
|
||||||
|
el?.tagName.toLowerCase() === "li",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/roughly 150 mb/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs the chrome channel when that is the one asked for", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||||
|
installBrowserViewBrowser.mockResolvedValue({
|
||||||
|
detection: { ...READY, chrome_channel: "/usr/bin/google-chrome-stable" },
|
||||||
|
log: "Installing google-chrome-stable",
|
||||||
|
browser_launched: true,
|
||||||
|
warning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
const button = await screen.findByRole("button", { name: /install chrome channel/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(installBrowserViewBrowser).toHaveBeenCalledWith("p1", "chrome"),
|
||||||
|
);
|
||||||
|
// Shown as the step's "done" line and again in the diagnostics table.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText(/google-chrome-stable/).length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an install failure with the real command output", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(NOTHING);
|
||||||
|
installBrowserViewSupport.mockRejectedValue(
|
||||||
|
"npm couldn't install Playwright in this container (exit 1).\n\nnpm said:\nEACCES: permission denied",
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
const button = await screen.findByRole("button", { name: /set up playwright/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/EACCES: permission denied/)).toBeInTheDocument();
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: "error" }),
|
||||||
|
);
|
||||||
|
expect(setContainerProgress).toHaveBeenCalledWith("p1", null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains precisely what is missing instead of spinning", async () => {
|
||||||
|
checkBrowserViewSupport.mockRejectedValue("container busy");
|
||||||
|
getBrowserViewStatus.mockResolvedValue({
|
||||||
|
...OFF,
|
||||||
|
enabled: true,
|
||||||
|
state: "unavailable",
|
||||||
|
message:
|
||||||
|
"Playwright isn't installed in this container. Two packages are needed: `playwright` and `@playwright/cli`.",
|
||||||
|
detection: NOTHING,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Two packages are needed/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unavailable")).toBeInTheDocument();
|
||||||
|
// The probe's findings are shown, so the user can see why.
|
||||||
|
expect(screen.getByText("22.11.0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("not in this build")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/usr\/lib\/node_modules/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a start failure rather than leaving the pane blank", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
setBrowserViewEnabled.mockRejectedValue("container went away");
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/didn’t start/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/container went away/)).toBeInTheDocument();
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: "error" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the view when asked", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue({
|
||||||
|
...OFF,
|
||||||
|
enabled: true,
|
||||||
|
state: "running",
|
||||||
|
url: "http://127.0.0.1:47821/?token=T",
|
||||||
|
host_port: 47821,
|
||||||
|
container_port: 39321,
|
||||||
|
});
|
||||||
|
setBrowserViewEnabled.mockResolvedValue(OFF);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(setBrowserViewEnabled).toHaveBeenCalled());
|
||||||
|
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
|
||||||
|
// The cache is full and every script fails \u2014 "install a browser" alone
|
||||||
|
// would read as nonsense, so the copy has to say which copy wants what.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({
|
||||||
|
...READY,
|
||||||
|
browsers: ["chromium-1237"],
|
||||||
|
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
|
||||||
|
chromium_executable_exists: true,
|
||||||
|
script_playwright_version: "1.62.1",
|
||||||
|
script_chromium_executable:
|
||||||
|
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
|
||||||
|
script_chromium_executable_exists: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
|
||||||
|
// Both revisions appear in the explanation: what is installed, and what
|
||||||
|
// the failing copy actually wants.
|
||||||
|
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call an unanswered probe a skew", async () => {
|
||||||
|
// A container older than these fields omits them; unknown is not broken.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only offers a window of its own once there is something to watch", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
|
||||||
|
await renderLive();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
|
||||||
|
// The window is showing it now — a second copy here would be a second
|
||||||
|
// cursor on the same page.
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||||
|
// Still live, and still stoppable from the tab.
|
||||||
|
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the view back in the tab when the window is closed from here", async () => {
|
||||||
|
await renderLive();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
|
||||||
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the window on top on request", async () => {
|
||||||
|
await renderLive();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// The accessible name is the visible text, as with every other Toggle.
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Keep on top" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
|
||||||
|
// The window belongs to the backend, so remounting the pane has to read its
|
||||||
|
// state back — otherwise the pane would render an iframe alongside it.
|
||||||
|
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
// The pin is read from the window too — the pane is unmounted every time
|
||||||
|
// another sub-tab is selected, so remembering it would show Off over a
|
||||||
|
// window that is still floating on top.
|
||||||
|
expect(screen.getByRole("switch", { name: "Keep on top" })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never mounts the iframe before the window's state is known", async () => {
|
||||||
|
// The status and the pop-out state are two separate round trips. If the
|
||||||
|
// status wins the race, guessing "not popped out" would flash a second
|
||||||
|
// viewer onto a browser the window is already driving.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
let answer: (s: { open: boolean; always_on_top: boolean }) => void = () => {};
|
||||||
|
getBrowserViewPopoutState.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Live")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
answer({ open: false, always_on_top: false });
|
||||||
|
});
|
||||||
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||||
|
await renderLive();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||||
|
target: { value: "http://localhost:5173" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||||
|
"p1",
|
||||||
|
"http://localhost:5173",
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||||
|
await renderLive();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||||
|
target: { value: "file:///etc/passwd" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||||
|
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers match-window only once the view is in its own window", async () => {
|
||||||
|
await renderLive();
|
||||||
|
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||||
|
await renderLive();
|
||||||
|
openBrowserViewPopout.mockRejectedValue("no display");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: "error", detail: "no display" }),
|
||||||
|
);
|
||||||
|
// The view is still in the tab, where it was.
|
||||||
|
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import type {
|
||||||
|
BrowserInstallTarget,
|
||||||
|
BrowserSetupOutcome,
|
||||||
|
BrowserViewChangedEvent,
|
||||||
|
BrowserViewPopoutChangedEvent,
|
||||||
|
BrowserViewStatus,
|
||||||
|
PlaywrightDetection,
|
||||||
|
Project,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
checkBrowserViewSupport,
|
||||||
|
closeBrowserViewPopout,
|
||||||
|
getBrowserViewStatus,
|
||||||
|
installBrowserViewBrowser,
|
||||||
|
installBrowserViewSupport,
|
||||||
|
getBrowserViewMatchWindow,
|
||||||
|
getBrowserViewPopoutState,
|
||||||
|
openBrowserViewPopout,
|
||||||
|
openPageInContainerBrowser,
|
||||||
|
setBrowserViewEnabled,
|
||||||
|
setBrowserViewMatchWindow,
|
||||||
|
setBrowserViewPopoutAlwaysOnTop,
|
||||||
|
} from "../../../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import OpenPageDialog from "./OpenPageDialog";
|
||||||
|
import AccordionSection from "../../ui/AccordionSection";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
import Toggle from "../../ui/Toggle";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFF: BrowserViewStatus = {
|
||||||
|
enabled: false,
|
||||||
|
state: "off",
|
||||||
|
url: null,
|
||||||
|
host_port: null,
|
||||||
|
container_port: null,
|
||||||
|
started_at: null,
|
||||||
|
detection: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which install is in flight. `null` means none — nothing installs itself. */
|
||||||
|
type SetupJob = null | "packages" | BrowserInstallTarget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch — and take over — the browser Claude is driving with Playwright inside
|
||||||
|
* the container.
|
||||||
|
*
|
||||||
|
* The pane is an iframe onto Playwright's own live dashboard, which runs in the
|
||||||
|
* container and is reached through a token-gated listener on the host's
|
||||||
|
* loopback. Nothing starts until the user asks: this is remote control of a
|
||||||
|
* browser in a privileged sandbox, so it is off by default and opted into per
|
||||||
|
* project, exactly like the auth bridge.
|
||||||
|
*
|
||||||
|
* The same rule, harder, applies to setup. Opening this tab *probes* the
|
||||||
|
* container (one `node -e`, read-only) so the pane can say what is missing
|
||||||
|
* before the user asks for a view — but it never installs anything. Installing
|
||||||
|
* packages and downloading a browser are container mutations measured in
|
||||||
|
* hundreds of megabytes; both are separate, labelled, user-pressed buttons.
|
||||||
|
*/
|
||||||
|
export default function BrowserTab({ project, active }: Props) {
|
||||||
|
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Bumped to force the iframe to reload without changing its src. */
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
/** Last read-only probe of the container, for the setup panel. */
|
||||||
|
const [detection, setDetection] = useState<PlaywrightDetection | null>(null);
|
||||||
|
const [job, setJob] = useState<SetupJob>(null);
|
||||||
|
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
||||||
|
const [setupError, setSetupError] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Whether the view is in its own window instead of this pane, and whether
|
||||||
|
* that window is pinned. `null` means "not asked yet" — a distinct state from
|
||||||
|
* "not popped out", because rendering the iframe on a guess is what puts a
|
||||||
|
* second viewer on the browser.
|
||||||
|
*/
|
||||||
|
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
||||||
|
const [onTop, setOnTop] = useState(false);
|
||||||
|
/** The "open a page" dialog, and the request it is running. */
|
||||||
|
const [matchWindow, setMatchWindow] = useState(false);
|
||||||
|
const [askPage, setAskPage] = useState(false);
|
||||||
|
const [openingPage, setOpeningPage] = useState(false);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||||
|
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
// The backend is the source of truth: it emits whenever a view starts or is
|
||||||
|
// torn down (container stopped, project removed, viewer died).
|
||||||
|
const projectId = project.id;
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
listen<BrowserViewChangedEvent>("browser-view-changed", (event) => {
|
||||||
|
if (event.payload.project_id === projectId && mounted.current) {
|
||||||
|
setStatus(event.payload.status);
|
||||||
|
}
|
||||||
|
}).then((un) => {
|
||||||
|
if (mounted.current) dispose = un;
|
||||||
|
else un();
|
||||||
|
});
|
||||||
|
return () => dispose?.();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// The window is the backend's, not this component's: it survives the tab
|
||||||
|
// being closed, the pane being unmounted and the view being torn down from
|
||||||
|
// elsewhere. So its state is listened for, never assumed.
|
||||||
|
useEffect(() => {
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
||||||
|
if (event.payload.project_id === projectId && mounted.current) {
|
||||||
|
setPoppedOut(event.payload.open);
|
||||||
|
setOnTop(event.payload.always_on_top);
|
||||||
|
}
|
||||||
|
}).then((un) => {
|
||||||
|
if (mounted.current) dispose = un;
|
||||||
|
else un();
|
||||||
|
});
|
||||||
|
return () => dispose?.();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !running) return;
|
||||||
|
getBrowserViewPopoutState(projectId)
|
||||||
|
.then((s) => {
|
||||||
|
if (!mounted.current) return;
|
||||||
|
setPoppedOut(s.open);
|
||||||
|
setOnTop(s.always_on_top);
|
||||||
|
})
|
||||||
|
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
||||||
|
// never show the view at all — so fail towards the tab.
|
||||||
|
.catch(() => mounted.current && setPoppedOut(false));
|
||||||
|
getBrowserViewMatchWindow(projectId)
|
||||||
|
.then((on) => mounted.current && setMatchWindow(on))
|
||||||
|
.catch(() => {});
|
||||||
|
getBrowserViewStatus(projectId)
|
||||||
|
.then((s) => mounted.current && setStatus(s))
|
||||||
|
.catch(() => {});
|
||||||
|
// Read-only. This is what lets the pane offer setup before the user hits a
|
||||||
|
// wall, and it is why a "not installed" answer is never stale.
|
||||||
|
checkBrowserViewSupport(projectId)
|
||||||
|
.then((d) => mounted.current && setDetection(d))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [active, projectId, running]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await setBrowserViewEnabled(projectId, next);
|
||||||
|
if (mounted.current) setStatus(result);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = String(e);
|
||||||
|
if (mounted.current) setError(detail);
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: next ? "Could not start the browser view" : "Could not stop the browser view",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop the view out, or pull it back.
|
||||||
|
*
|
||||||
|
* Both are window operations only — the viewer keeps running either way — so
|
||||||
|
* this is cheap enough to toggle freely and never interrupts what the agent
|
||||||
|
* is doing in the browser.
|
||||||
|
*/
|
||||||
|
const popOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await openBrowserViewPopout(projectId, onTop);
|
||||||
|
if (mounted.current) setPoppedOut(true);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not open the browser in its own window",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [projectId, onTop, pushToast]);
|
||||||
|
|
||||||
|
const popIn = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await closeBrowserViewPopout(projectId);
|
||||||
|
if (mounted.current) setPoppedOut(false);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not close the browser window",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [projectId, pushToast]);
|
||||||
|
|
||||||
|
const toggleOnTop = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setOnTop(next);
|
||||||
|
try {
|
||||||
|
await setBrowserViewPopoutAlwaysOnTop(projectId, next);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted.current) setOnTop(!next);
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not change the window's stacking",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a URL in a browser inside the container.
|
||||||
|
*
|
||||||
|
* The pane only ever *watched* browsers something else published; this is the
|
||||||
|
* one action that opens one. It also means the page can be resized later —
|
||||||
|
* whoever launches a bound browser is the only process that can drive it.
|
||||||
|
*/
|
||||||
|
const openPage = useCallback(
|
||||||
|
async (url: string, width: number, height: number) => {
|
||||||
|
setOpeningPage(true);
|
||||||
|
try {
|
||||||
|
const result = await openPageInContainerBrowser(projectId, url, width, height);
|
||||||
|
if (!mounted.current) return;
|
||||||
|
setAskPage(false);
|
||||||
|
if (result.error) {
|
||||||
|
pushToast({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||||
|
} else {
|
||||||
|
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not open the page in the container’s browser",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setOpeningPage(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMatchWindow = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setMatchWindow(next);
|
||||||
|
try {
|
||||||
|
await setBrowserViewMatchWindow(projectId, next);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted.current) setMatchWindow(!next);
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not match the page to the window",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Run one install. Every path clears the progress line it started. */
|
||||||
|
const install = useCallback(
|
||||||
|
async (which: Exclude<SetupJob, null>) => {
|
||||||
|
setJob(which);
|
||||||
|
setSetupError(null);
|
||||||
|
setOutcome(null);
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
which === "packages"
|
||||||
|
? await installBrowserViewSupport(projectId)
|
||||||
|
: await installBrowserViewBrowser(projectId, which);
|
||||||
|
if (!mounted.current) return;
|
||||||
|
// The command re-probes, so the pane updates itself — no reopening the
|
||||||
|
// tab, no second button to press.
|
||||||
|
setDetection(result.detection);
|
||||||
|
setOutcome(result);
|
||||||
|
if (result.warning) {
|
||||||
|
// Not an error — the step did what it said — but the caveat is the
|
||||||
|
// part that decides whether the browser will actually work.
|
||||||
|
pushToast({
|
||||||
|
kind: "info",
|
||||||
|
message: "Setup finished, with something to know",
|
||||||
|
detail: result.warning,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message:
|
||||||
|
which === "packages" ? "Playwright installed" : `${which} installed and verified`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const detail = String(e);
|
||||||
|
if (mounted.current) setSetupError(detail);
|
||||||
|
pushToast({ kind: "error", message: "Setup failed", detail });
|
||||||
|
} finally {
|
||||||
|
setContainerProgress(projectId, null);
|
||||||
|
if (mounted.current) setJob(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast, setContainerProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
// A stopped container can't be hosting a browser — and can't be installed
|
||||||
|
// into either, so say that plainly rather than offering controls that would
|
||||||
|
// only fail.
|
||||||
|
if (!running) {
|
||||||
|
return (
|
||||||
|
<Explainer title="The container isn’t running.">
|
||||||
|
Start the container, have Claude drive a browser with Playwright, then come
|
||||||
|
back here to watch it.
|
||||||
|
</Explainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = status.state === "running" && status.url;
|
||||||
|
// Prefer the probe: it is the fresher of the two, and it is the one that
|
||||||
|
// reflects an install that just finished.
|
||||||
|
const probed = detection ?? status.detection;
|
||||||
|
const ready = isUsable(probed);
|
||||||
|
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
||||||
|
// apt package, so it never shows up in `browsers`, and a container that has
|
||||||
|
// it is not missing a browser.
|
||||||
|
const needsBrowser =
|
||||||
|
probed !== null &&
|
||||||
|
probed.chrome_channel === null &&
|
||||||
|
(probed.browsers.length === 0 || revisionSkew(probed));
|
||||||
|
const needsSetup = probed !== null && (!ready || needsBrowser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] flex-shrink-0 flex-wrap">
|
||||||
|
<StatusIndicator
|
||||||
|
tone={
|
||||||
|
busy
|
||||||
|
? "busy"
|
||||||
|
: status.state === "running"
|
||||||
|
? "running"
|
||||||
|
: status.state === "unavailable"
|
||||||
|
? "error"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
busy
|
||||||
|
? "Starting"
|
||||||
|
: status.state === "running"
|
||||||
|
? "Live"
|
||||||
|
: status.state === "unavailable"
|
||||||
|
? "Unavailable"
|
||||||
|
: "Off"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{live && (
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||||
|
127.0.0.1:{status.host_port} → container :{status.container_port}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{progress && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] min-w-0"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<StatusIndicator tone="busy" label="" />
|
||||||
|
<span className="truncate">{progress}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{live && poppedOut === true && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
Keep on top
|
||||||
|
{/* The accessible name matches the visible text, as everywhere else
|
||||||
|
a Toggle is used — a `<label>` around it would be inert anyway,
|
||||||
|
since a Toggle renders a button. */}
|
||||||
|
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{live && poppedOut === true && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
|
||||||
|
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
|
||||||
|
>
|
||||||
|
Match window
|
||||||
|
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{live && poppedOut === false && (
|
||||||
|
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{live && (
|
||||||
|
<Button size="md" onClick={() => setAskPage(true)}>
|
||||||
|
Open a page…
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{live && poppedOut !== null && (
|
||||||
|
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||||
|
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant={live ? "secondary" : "primary"}
|
||||||
|
disabled={busy || job !== null}
|
||||||
|
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{live && poppedOut === true ? (
|
||||||
|
// The iframe is unmounted while the window is up, on purpose. Two
|
||||||
|
// viewers on one browser both work, but both also *drive* it — two
|
||||||
|
// cursors taking over the same page is not a feature.
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-[28rem] text-center">
|
||||||
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||||
|
This view is in its own window.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
Move it to another screen, or keep it on top, and watch the browser while
|
||||||
|
you work here. The view keeps running either way — closing the window
|
||||||
|
brings it back into this tab.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2">
|
||||||
|
<Button size="md" variant="primary" onClick={popIn}>
|
||||||
|
Put back in tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : live && poppedOut === false ? (
|
||||||
|
<iframe
|
||||||
|
key={reloadKey}
|
||||||
|
// Loopback only, and the URL carries the one-time session token the
|
||||||
|
// host-side gate checks before anything reaches the container.
|
||||||
|
src={status.url ?? undefined}
|
||||||
|
title={`Playwright browser view for ${project.name}`}
|
||||||
|
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||||
|
/>
|
||||||
|
) : live ? (
|
||||||
|
// Live, but the window's state hasn't come back yet. An instant, and
|
||||||
|
// deliberately empty: guessing "not popped out" here is what would
|
||||||
|
// flash a second viewer onto the browser.
|
||||||
|
<div className="flex-1 min-h-0" />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{/* Setup stays on screen while an install is running and after it
|
||||||
|
finishes, so its output and caveats don't vanish at the moment
|
||||||
|
they become readable. */}
|
||||||
|
{needsSetup ||
|
||||||
|
status.state === "unavailable" ||
|
||||||
|
job !== null ||
|
||||||
|
outcome !== null ||
|
||||||
|
setupError !== null ? (
|
||||||
|
<Setup
|
||||||
|
detection={probed}
|
||||||
|
message={status.state === "unavailable" ? status.message : null}
|
||||||
|
job={job}
|
||||||
|
progress={job ? progress : undefined}
|
||||||
|
outcome={outcome}
|
||||||
|
error={setupError}
|
||||||
|
onInstall={install}
|
||||||
|
/>
|
||||||
|
) : error ? (
|
||||||
|
<Explainer title="The browser view didn’t start." tone="error">
|
||||||
|
<span className="font-mono text-xs break-words">{error}</span>
|
||||||
|
</Explainer>
|
||||||
|
) : (
|
||||||
|
<Explainer title="Nothing is being watched yet.">
|
||||||
|
Start the view to run Playwright’s live dashboard inside this container
|
||||||
|
and mirror it here. You’ll see any browser a script has published with{" "}
|
||||||
|
<Code>await browser.bind('claude')</Code> — and{" "}
|
||||||
|
<Code>@playwright/mcp</Code> publishes automatically, so nothing extra is
|
||||||
|
needed if Claude is using that.
|
||||||
|
</Explainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{askPage && (
|
||||||
|
<OpenPageDialog
|
||||||
|
busy={openingPage}
|
||||||
|
onOpen={openPage}
|
||||||
|
onClose={() => setAskPage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
|
||||||
|
function isUsable(d: PlaywrightDetection | null): boolean {
|
||||||
|
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||||
|
*
|
||||||
|
* Browsers are installed, but not the revision one of the two Playwright copies
|
||||||
|
* would launch — so the cache looks full and launches fail. A probe that didn't
|
||||||
|
* answer leaves the executable null, and "unknown" must not read as "broken".
|
||||||
|
*/
|
||||||
|
function revisionSkew(d: PlaywrightDetection | null): boolean {
|
||||||
|
if (!d || d.browsers.length === 0) return false;
|
||||||
|
// `!= null`, not `!== null`: a probe from a container that predates these
|
||||||
|
// fields omits them entirely, and `undefined` is "didn't answer" — which must
|
||||||
|
// never render as "your browsers are wrong".
|
||||||
|
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
|
||||||
|
const scriptsBroken =
|
||||||
|
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
|
||||||
|
return viewerBroken || scriptsBroken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The skew sentence, naming both halves.
|
||||||
|
*
|
||||||
|
* "Install a browser" over a cache that visibly already holds one reads as
|
||||||
|
* nonsense, so the copy has to say which copy of Playwright wants what.
|
||||||
|
*/
|
||||||
|
function skewText(d: PlaywrightDetection | null): string {
|
||||||
|
if (!d) return "";
|
||||||
|
const scriptsBroken =
|
||||||
|
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
|
||||||
|
const [version, wanted] = scriptsBroken
|
||||||
|
? [d.script_playwright_version, d.script_chromium_executable]
|
||||||
|
: [d.playwright_version, d.chromium_executable];
|
||||||
|
return (
|
||||||
|
`This container has ${d.browsers.join(", ")}, but ` +
|
||||||
|
`${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
|
||||||
|
` — ${version ?? "?"} — launches ${wanted ?? "?"}, which isn’t there. ` +
|
||||||
|
(scriptsBroken
|
||||||
|
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
|
||||||
|
: "Install Chromium below: it runs that build’s own installer, so it fetches exactly the revision that is missing.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the container is short of, as a list rather than as prose. */
|
||||||
|
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||||
|
if (!d) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!d.node_version) out.push("Node.js");
|
||||||
|
if (!d.playwright_version) out.push("playwright");
|
||||||
|
else if (!d.has_bind) out.push("a newer playwright — this build has no browser.bind()");
|
||||||
|
if (!d.cli_entry) out.push("@playwright/cli");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup, as one action per line, each saying what it costs before it is
|
||||||
|
* pressed.
|
||||||
|
*
|
||||||
|
* The old pane printed npm commands here and left the rest to the user. The
|
||||||
|
* result, verified with a real one: an `@playwright/mcp` install that could
|
||||||
|
* never satisfy this pane, a global install that hit EACCES, a Chromium that
|
||||||
|
* downloaded and then would not start because the image shipped none of its
|
||||||
|
* shared libraries, and a long tail of commands after that. Current base images
|
||||||
|
* bake those libraries in, so that last one is fixed at the source — but a
|
||||||
|
* project keeps its original base image until it is migrated, so the install
|
||||||
|
* action still handles a container that lacks them.
|
||||||
|
*/
|
||||||
|
function Setup({
|
||||||
|
detection,
|
||||||
|
message,
|
||||||
|
job,
|
||||||
|
progress,
|
||||||
|
outcome,
|
||||||
|
error,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
detection: PlaywrightDetection | null;
|
||||||
|
message: string | null;
|
||||||
|
job: SetupJob;
|
||||||
|
progress?: string;
|
||||||
|
outcome: BrowserSetupOutcome | null;
|
||||||
|
error: string | null;
|
||||||
|
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||||
|
}) {
|
||||||
|
const busy = job !== null;
|
||||||
|
const havePackages = isUsable(detection);
|
||||||
|
const missing = missingParts(detection);
|
||||||
|
const browsers = detection?.browsers ?? [];
|
||||||
|
const chrome = detection?.chrome_channel ?? null;
|
||||||
|
const noBrowser = browsers.length === 0 && chrome === null;
|
||||||
|
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
|
||||||
|
// because the fix is the same button but the sentence must not be "install a
|
||||||
|
// browser" over a cache that visibly has one.
|
||||||
|
const skew = revisionSkew(detection) && chrome === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[46rem] space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||||
|
{!havePackages
|
||||||
|
? "This container can’t serve a browser view yet"
|
||||||
|
: skew
|
||||||
|
? "The installed browser isn’t the one Playwright launches"
|
||||||
|
: noBrowser
|
||||||
|
? "Playwright is ready — but there’s no browser to drive yet"
|
||||||
|
: "This container is set up"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{message ??
|
||||||
|
(missing.length > 0
|
||||||
|
? `Missing: ${missing.join(", ")}.`
|
||||||
|
: skew
|
||||||
|
? skewText(detection)
|
||||||
|
: noBrowser
|
||||||
|
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
||||||
|
: "Start the view from the button above once Claude has a browser open.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title="1. Playwright and the viewer UI"
|
||||||
|
detail={
|
||||||
|
<>
|
||||||
|
Installs <Code>playwright</Code> and <Code>@playwright/cli</Code> into{" "}
|
||||||
|
<Code>/workspace/node_modules</Code> inside the container. That directory is
|
||||||
|
container storage — your project folders are mounted one level down, so
|
||||||
|
nothing of yours is touched — and no <Code>sudo</Code> is involved. Small
|
||||||
|
download; browsers come next.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
done={havePackages}
|
||||||
|
doneLabel={`Installed — playwright ${detection?.playwright_version ?? ""}, @playwright/cli ${detection?.cli_version ?? ""}`}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant={havePackages ? "secondary" : "primary"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onInstall("packages")}
|
||||||
|
>
|
||||||
|
{job === "packages" ? "Installing…" : havePackages ? "Reinstall" : "Set up Playwright"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title="2. A browser to drive"
|
||||||
|
detail={
|
||||||
|
<>
|
||||||
|
Both check the system libraries a browser links against first. Current base
|
||||||
|
images ship them, so that step is normally skipped; a container built from an
|
||||||
|
older image gets them installed with apt, which is the difference between a
|
||||||
|
browser that downloads successfully and one that also starts. Both end by
|
||||||
|
actually launching the browser to prove it works. Browsers land in{" "}
|
||||||
|
<Code>~/.cache/ms-playwright</Code>, which is on the home volume, so they
|
||||||
|
survive container recreation and are only lost on a project Reset.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
done={browsers.length > 0 || chrome !== null}
|
||||||
|
doneLabel={[
|
||||||
|
browsers.length > 0 ? browsers.join(", ") : null,
|
||||||
|
chrome ? `Chrome channel (${chrome})` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-col gap-2 items-end">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant={browsers.length > 0 || !havePackages ? "secondary" : "primary"}
|
||||||
|
disabled={busy || !havePackages}
|
||||||
|
onClick={() => onInstall("chromium")}
|
||||||
|
>
|
||||||
|
{job === "chromium" ? "Installing…" : "Install Chromium"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
disabled={busy || !havePackages}
|
||||||
|
onClick={() => onInstall("chrome")}
|
||||||
|
>
|
||||||
|
{job === "chrome" ? "Installing…" : "Install Chrome channel"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
<li>
|
||||||
|
<strong className="text-[var(--text-primary)]">Chromium</strong> — Playwright’s
|
||||||
|
own build, used by <Code>chromium.launch()</Code> with no channel. Several
|
||||||
|
hundred MB.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-[var(--text-primary)]">Chrome channel</strong> — Google
|
||||||
|
Chrome from apt, which is what <Code>@playwright/mcp</Code> asks for. Install
|
||||||
|
this one if Claude drives the browser through the MCP plugin. Roughly 150 MB.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<p
|
||||||
|
className="text-xs font-mono text-[var(--text-secondary)] break-all"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{progress ?? "Working…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-[var(--error)]">
|
||||||
|
<p className="font-semibold">That didn’t work.</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap font-mono break-words text-[var(--text-secondary)]">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outcome?.warning && (
|
||||||
|
<div className="text-xs text-[var(--text-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
||||||
|
<p className="font-semibold">Worth knowing</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{outcome.warning}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outcome?.log && (
|
||||||
|
<AccordionSection
|
||||||
|
id="browser-view-install-log"
|
||||||
|
title="Install output"
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-64 overflow-y-auto">
|
||||||
|
{outcome.log}
|
||||||
|
</pre>
|
||||||
|
</AccordionSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detection && (
|
||||||
|
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-3 border-t border-[var(--border-color)]">
|
||||||
|
<Detail label="Node.js" value={detection.node_version} />
|
||||||
|
<Detail label="Playwright" value={detection.playwright_version} />
|
||||||
|
<Detail label="Resolved from" value={detection.playwright_path} />
|
||||||
|
<Detail
|
||||||
|
label="browser.bind()"
|
||||||
|
value={detection.has_bind ? "available" : "not in this build"}
|
||||||
|
/>
|
||||||
|
<Detail label="@playwright/cli" value={detection.cli_version} />
|
||||||
|
<Detail
|
||||||
|
label="Browsers"
|
||||||
|
value={browsers.length > 0 ? browsers.join(", ") : null}
|
||||||
|
/>
|
||||||
|
<Detail label="Chrome channel" value={chrome} />
|
||||||
|
{detection.searched.length > 0 && (
|
||||||
|
<Detail label="Searched" value={detection.searched.join(", ")} />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One numbered setup step: what it does, whether it is done, and its button. */
|
||||||
|
function Step({
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
done,
|
||||||
|
doneLabel,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
detail: React.ReactNode;
|
||||||
|
done: boolean;
|
||||||
|
doneLabel?: string;
|
||||||
|
action: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||||
|
<StatusIndicator tone={done ? "ok" : "off"} label={done ? "Installed" : "Not installed"} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-relaxed">{detail}</p>
|
||||||
|
{done && doneLabel && (
|
||||||
|
<p className="mt-1 text-xs font-mono text-[var(--text-secondary)] break-all">
|
||||||
|
{doneLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">{action}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ label, value }: { label: string; value: string | null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt className="text-[var(--text-secondary)]">{label}</dt>
|
||||||
|
<dd className="font-mono text-[var(--text-primary)] break-all">
|
||||||
|
{value ?? "not found"}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Explainer({
|
||||||
|
title,
|
||||||
|
tone = "normal",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
tone?: "normal" | "error";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[46rem]">
|
||||||
|
<h2
|
||||||
|
className={`text-[13px] font-semibold ${
|
||||||
|
tone === "error" ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="font-mono text-xs px-1 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationReport,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
|
||||||
|
const FRESH: ContainerStaleness = {
|
||||||
|
stale: false,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:aaa",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: [],
|
||||||
|
missing_features: [],
|
||||||
|
apt_delta: [],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
unpreserved_data: [],
|
||||||
|
outdated_package_count: 0,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
...FRESH,
|
||||||
|
stale: true,
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"],
|
||||||
|
missing_features: [
|
||||||
|
"Host-browser opening",
|
||||||
|
"Auth bridge tunnel (socat)",
|
||||||
|
"Mission Control",
|
||||||
|
],
|
||||||
|
apt_delta: ["socat", "bubblewrap"],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||||
|
return {
|
||||||
|
staleness: null,
|
||||||
|
probing: false,
|
||||||
|
probeSettled: true,
|
||||||
|
running: false,
|
||||||
|
recovered: false,
|
||||||
|
interrupted: null,
|
||||||
|
report: null,
|
||||||
|
log: [],
|
||||||
|
phaseMessage: null,
|
||||||
|
busy: false,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
keep: vi.fn(async () => {}),
|
||||||
|
rollback: vi.fn(async () => {}),
|
||||||
|
dismiss: vi.fn(async () => {}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBanner(m: ContainerMigration, canMigrate = true) {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ContainerMigrationBanner migration={m} canMigrate={canMigrate} onOpen={onOpen} />,
|
||||||
|
);
|
||||||
|
return { onOpen, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ContainerMigrationBanner", () => {
|
||||||
|
it("renders nothing when the container is on the current base", () => {
|
||||||
|
const { container } = renderBanner(migration({ staleness: FRESH }));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing before the probe has returned", () => {
|
||||||
|
const { container } = renderBanner(migration({ staleness: null }));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leads with the missing features rather than image digests", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }));
|
||||||
|
expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/61 packages differ from the versions on the current base/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Digests are evidence, not the message.
|
||||||
|
expect(screen.queryByText(/sha256/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim the packages are behind, only that they differ", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }));
|
||||||
|
// `outdated_package_count` is a drift measure; the backend explicitly does
|
||||||
|
// not promise every one of them is newer.
|
||||||
|
expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the container was probed when there is no base-image label", () => {
|
||||||
|
// `stale` is always false when `known` is false — an unknown lineage is not
|
||||||
|
// a claim of staleness — but the probe's own findings still have to show.
|
||||||
|
renderBanner(
|
||||||
|
migration({ staleness: { ...STALE, known: false, stale: false } }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/probed directly/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument();
|
||||||
|
// No version comparison happened, so none is implied.
|
||||||
|
expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet for an unlabelled container the probe found nothing wrong with", () => {
|
||||||
|
const { container } = renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: {
|
||||||
|
...FRESH,
|
||||||
|
known: false,
|
||||||
|
stale: false,
|
||||||
|
outdated_package_count: 3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("speaks up when an unlabelled container could not be probed at all", () => {
|
||||||
|
// The probe is the only signal a container with no lineage label has. If
|
||||||
|
// it fails and the banner stays silent, that is indistinguishable from
|
||||||
|
// "up to date" — the exact reading that let an out-of-date project go
|
||||||
|
// unnoticed indefinitely.
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: {
|
||||||
|
...FRESH,
|
||||||
|
known: false,
|
||||||
|
stale: false,
|
||||||
|
probe_error: "output exceeded the inspection limit",
|
||||||
|
},
|
||||||
|
probeSettled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Container base could not be checked/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/output exceeded the inspection limit/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// And it must not pose as a finding about the container itself.
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Container is missing things/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when a labelled container's probe fails but its lineage is current", () => {
|
||||||
|
// `known` means the version comparison already answered the question, so
|
||||||
|
// a failed probe is not grounds to raise anything.
|
||||||
|
const { container } = renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: { ...FRESH, probe_error: "could not exec in the container" },
|
||||||
|
probeSettled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the action and explains why while the container is running", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }), false);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Update container base/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps reporting an in-flight run after the modal is closed", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
running: true,
|
||||||
|
phaseMessage: "Reinstalling socat…",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Updating container base/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a run recovered from a crash", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE, running: true, recovered: true }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(/A container base update was already running/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/still in progress when the app last closed/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an interrupted migration hide behind a plain staleness notice", () => {
|
||||||
|
const m = migration({
|
||||||
|
staleness: STALE,
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: false, keep_rollback: true },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderBanner(m);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/The container base update did not finish/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument();
|
||||||
|
// The plain "Update container base…" call to action must not be what is
|
||||||
|
// offered here — the container is mid-swap, so it is resume or roll back.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Update container base/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||||
|
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no rollback for an interrupted run that kept no rollback image", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: null,
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: false, keep_rollback: false },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes an unsettled probe from a running container", () => {
|
||||||
|
// "Stop the container to update its base" on a container that is already
|
||||||
|
// stopped — because the probe has not landed — reads as a bug.
|
||||||
|
renderBanner(
|
||||||
|
migration({ staleness: STALE, probing: true, probeSettled: false }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Checking what this container has/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Stop the container to update its base/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the /var data that updating would destroy", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: {
|
||||||
|
...STALE,
|
||||||
|
unpreserved_data: [
|
||||||
|
{ path: "/var/lib/postgresql", bytes: 41_000_000, file_count: 912 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("/var/lib/postgresql")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/back this up before updating/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the report", () => {
|
||||||
|
const CLEAN: MigrationReport = {
|
||||||
|
phase: "succeeded",
|
||||||
|
packages_requested: ["socat", "bubblewrap"],
|
||||||
|
packages_installed: [
|
||||||
|
"socat",
|
||||||
|
"bubblewrap",
|
||||||
|
"ca-certificates",
|
||||||
|
"openssl",
|
||||||
|
"curl",
|
||||||
|
"jq",
|
||||||
|
"ripgrep",
|
||||||
|
"unzip",
|
||||||
|
],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [
|
||||||
|
"Host-browser opening",
|
||||||
|
"Auth bridge tunnel (socat)",
|
||||||
|
"Sandbox mode (bubblewrap)",
|
||||||
|
"Mission Control",
|
||||||
|
],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTIAL: MigrationReport = {
|
||||||
|
phase: "partial",
|
||||||
|
packages_requested: ["socat", "bubblewrap", "libfoo-dev"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [
|
||||||
|
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||||
|
{ name: "libfoo-dev", reason: "no installation candidate in noble" },
|
||||||
|
],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports a clean run with counts and both choices", () => {
|
||||||
|
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||||
|
expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/4 features restored/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names every failed package and why, and does not read as a success", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE, report: PARTIAL }));
|
||||||
|
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("libfoo-dev")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/no installation candidate in noble/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// And the exact line that finishes the job by hand.
|
||||||
|
expect(
|
||||||
|
screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy apt-get line/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a failed run has already been restored, and offers no rollback", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
report: {
|
||||||
|
phase: "failed",
|
||||||
|
packages_requested: [],
|
||||||
|
packages_installed: [],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [],
|
||||||
|
rollback_available: false,
|
||||||
|
message:
|
||||||
|
"Update failed at replay. Your container has been restored to its previous state.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never offers Keep over a container that is still mid-swap", () => {
|
||||||
|
// The failing-commit path returns a report *and* leaves the record
|
||||||
|
// interrupted. Keep would untag the rollback image and delete the record
|
||||||
|
// while `triple-c-snapshot-<id>:latest` still points at the old lineage —
|
||||||
|
// and the backend's own message on that record says to resume.
|
||||||
|
const m = migration({
|
||||||
|
staleness: STALE,
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-20260809-100000",
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: true, keep_rollback: true },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
...CLEAN,
|
||||||
|
phase: "failed",
|
||||||
|
message: "saving it failed. Resume it, or roll back.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderBanner(m);
|
||||||
|
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Resume update" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Roll back" }));
|
||||||
|
expect(m.rollback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not describe rollback as a time machine", () => {
|
||||||
|
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Rollback restores the system layer only/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
import MigrationReportCard from "../MigrationReportCard";
|
||||||
|
import MigrationInterruptedCard from "../MigrationInterruptedCard";
|
||||||
|
import { formatSnapshotDate, joinFeatures } from "../migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
migration: ContainerMigration;
|
||||||
|
/** Migration mirrors Reset's gate: the container has to be stopped. */
|
||||||
|
canMigrate: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELL =
|
||||||
|
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Overview answer to "why is this container behaving oddly?".
|
||||||
|
*
|
||||||
|
* It leads with the *features* that are missing, not image digests: a user does
|
||||||
|
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
|
||||||
|
* that host-browser opening and the auth bridge do not work. Digests are the
|
||||||
|
* evidence, not the message.
|
||||||
|
*
|
||||||
|
* It also has to survive the run: an in-flight migration, an interrupted one,
|
||||||
|
* and the report are all shown here, because the modal is dismissable and the
|
||||||
|
* outcome must not vanish with it.
|
||||||
|
*/
|
||||||
|
export default function ContainerMigrationBanner({
|
||||||
|
migration,
|
||||||
|
canMigrate,
|
||||||
|
onOpen,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
staleness,
|
||||||
|
probing,
|
||||||
|
probeSettled,
|
||||||
|
running,
|
||||||
|
recovered,
|
||||||
|
interrupted,
|
||||||
|
report,
|
||||||
|
phaseMessage,
|
||||||
|
busy,
|
||||||
|
} = migration;
|
||||||
|
|
||||||
|
// An unfinished migration outranks its own report. The report's action row
|
||||||
|
// offers Keep, and Keep on a mid-swap container drops the rollback image
|
||||||
|
// while `:latest` still points at the old lineage — the backend's message on
|
||||||
|
// the very same record says to resume. Resume is the only honest primary
|
||||||
|
// action here, so the report card is not rendered at all.
|
||||||
|
if (interrupted) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
|
||||||
|
aria-label="Container base update was interrupted"
|
||||||
|
>
|
||||||
|
<MigrationInterruptedCard
|
||||||
|
record={interrupted}
|
||||||
|
busy={busy || running}
|
||||||
|
onResume={() => void migration.resume()}
|
||||||
|
onRollback={() => void migration.rollback()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The report outranks staleness: after a run, the outcome is the news.
|
||||||
|
if (report) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} ${
|
||||||
|
report.phase === "partial" || report.phase === "failed"
|
||||||
|
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
|
||||||
|
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
||||||
|
}`}
|
||||||
|
aria-label="Container base update result"
|
||||||
|
>
|
||||||
|
<MigrationReportCard
|
||||||
|
report={report}
|
||||||
|
busy={busy}
|
||||||
|
onKeep={() => void migration.keep()}
|
||||||
|
onRollback={() => void migration.rollback()}
|
||||||
|
onDismiss={() => void migration.dismiss()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||||
|
aria-label="Container base update in progress"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<StatusIndicator
|
||||||
|
tone="busy"
|
||||||
|
label={
|
||||||
|
recovered
|
||||||
|
? "A container base update was already running"
|
||||||
|
: "Updating container base"
|
||||||
|
}
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{phaseMessage ?? "Starting…"}
|
||||||
|
</p>
|
||||||
|
{recovered && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
It was still in progress when the app last closed. Picking it back up.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="md" onClick={onOpen}>
|
||||||
|
Show progress
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!staleness) return null;
|
||||||
|
|
||||||
|
// `stale` is deliberately false whenever `known` is false — an unestablished
|
||||||
|
// lineage is not a claim of staleness. But a container with no base-image
|
||||||
|
// label is exactly the old container most likely to be missing things, and
|
||||||
|
// the probe says so directly. So the probe's own findings are grounds to
|
||||||
|
// speak up even though the version comparison never happened.
|
||||||
|
const probeFoundGaps =
|
||||||
|
!staleness.known &&
|
||||||
|
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
||||||
|
|
||||||
|
// The probe is the *only* signal a container with no lineage label has, so
|
||||||
|
// when it fails there is nothing left to be quiet about. Staying silent here
|
||||||
|
// is indistinguishable from "everything is fine" — and it is the likeliest
|
||||||
|
// outcome for the oldest, largest projects, whose manifests are the ones apt
|
||||||
|
// to exceed the inspection limit. Say that the check did not run instead.
|
||||||
|
const probeUnavailable = !staleness.known && !!staleness.probe_error;
|
||||||
|
|
||||||
|
if (!staleness.stale && !probeFoundGaps && !probeUnavailable) return null;
|
||||||
|
|
||||||
|
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||||
|
const features = joinFeatures(staleness.missing_features);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||||
|
aria-label={
|
||||||
|
probeUnavailable
|
||||||
|
? "Container base could not be checked"
|
||||||
|
: "Container base is out of date"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<StatusIndicator
|
||||||
|
// A check that could not run is not a finding: it gets the
|
||||||
|
// "unresolved" tone rather than the one that says something is
|
||||||
|
// wrong with the container.
|
||||||
|
tone={probeUnavailable ? "unknown" : "error"}
|
||||||
|
label={
|
||||||
|
staleness.known
|
||||||
|
? "Container base is out of date"
|
||||||
|
: probeUnavailable
|
||||||
|
? "Container base could not be checked"
|
||||||
|
: "Container is missing things the current base ships"
|
||||||
|
}
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{staleness.known
|
||||||
|
? snapshot
|
||||||
|
? `Running on a saved image from ${snapshot}.`
|
||||||
|
: "Running on a saved image older than the current base."
|
||||||
|
: probeUnavailable
|
||||||
|
? "This container predates base-image tracking, so probing it is the only way to tell whether it is behind — and that did not complete."
|
||||||
|
: "This container predates base-image tracking, so it was probed directly."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{staleness.missing_features.length > 0 && (
|
||||||
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||||
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||||
|
<span className="text-[var(--text-secondary)]">{features}.</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{staleness.missing_features.length === 0 &&
|
||||||
|
staleness.missing_paths.length > 0 && (
|
||||||
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||||
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||||
|
<span className="font-mono text-[var(--text-secondary)]">
|
||||||
|
{staleness.missing_paths.join(", ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deliberately "differ" rather than "behind": the count is a drift
|
||||||
|
measure, not a promise that every one of them is newer. */}
|
||||||
|
{staleness.outdated_package_count > 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{staleness.outdated_package_count} package
|
||||||
|
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
|
||||||
|
versions on the current base, where security updates land.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{staleness.probe_error && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Some checks did not complete: {staleness.probe_error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* An out-of-date container that also has data under /var is the one
|
||||||
|
case where updating can cost something, so it is said here and not
|
||||||
|
only behind the button. */}
|
||||||
|
{staleness.unpreserved_data.length > 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-primary)] leading-snug">
|
||||||
|
Not carried across:{" "}
|
||||||
|
<span className="font-mono text-[var(--text-secondary)]">
|
||||||
|
{staleness.unpreserved_data.map((d) => d.path).join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{" "}
|
||||||
|
— back this up before updating.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canMigrate && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{/* Distinguishing these matters: "stop the container" on a
|
||||||
|
container that is already stopped, because the probe has not
|
||||||
|
landed, reads as a bug. */}
|
||||||
|
{!probeSettled
|
||||||
|
? probing
|
||||||
|
? "Checking what this container has that the current base does not…"
|
||||||
|
: "That check did not complete, so what would be carried across is not known. Updating stays disabled until it does — try again once the container can be inspected."
|
||||||
|
: "Stop the container to update its base."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canMigrate}
|
||||||
|
onClick={onOpen}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Update container base…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport presets. These are the *page's* resolution, not the window's — the
|
||||||
|
* pane is a screencast, so a bigger window shows the same pixels drawn larger
|
||||||
|
* while this is what actually reflows the layout.
|
||||||
|
*/
|
||||||
|
const PRESETS: { label: string; width: number; height: number }[] = [
|
||||||
|
{ label: "1280 × 720", width: 1280, height: 720 },
|
||||||
|
{ label: "1920 × 1080", width: 1920, height: 1080 },
|
||||||
|
{ label: "1440 × 900", width: 1440, height: 900 },
|
||||||
|
{ label: "390 × 844 (phone)", width: 390, height: 844 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
|
||||||
|
initialUrl?: string;
|
||||||
|
initialWidth?: number;
|
||||||
|
initialHeight?: number;
|
||||||
|
busy?: boolean;
|
||||||
|
onOpen: (url: string, width: number, height: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a URL and a viewport, then open it in the container's browser.
|
||||||
|
*
|
||||||
|
* Deliberately modal and short-lived — the convention for a task with one
|
||||||
|
* question and one button. The URL is not opened here; the caller runs the
|
||||||
|
* command so failures land in its toast.
|
||||||
|
*/
|
||||||
|
export default function OpenPageDialog({
|
||||||
|
initialUrl = "",
|
||||||
|
initialWidth = 1280,
|
||||||
|
initialHeight = 720,
|
||||||
|
busy = false,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [url, setUrl] = useState(initialUrl);
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const [height, setHeight] = useState(initialHeight);
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
// Mirrors the backend's allow-list, so the error arrives before the click
|
||||||
|
// rather than after a round trip.
|
||||||
|
const valid = /^https?:\/\/\S+$/i.test(trimmed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Open a page in the container's browser"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!valid || busy}
|
||||||
|
onClick={() => onOpen(trimmed, width, height)}
|
||||||
|
>
|
||||||
|
{busy ? "Opening…" : "Open page"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
Launches a browser <em>inside</em> this container and publishes it to the
|
||||||
|
Browser tab. Use it for a sign-in page — the callback listener is in the
|
||||||
|
container too, so the login completes without involving your host browser —
|
||||||
|
or for a dev server on container loopback.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">URL</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
|
||||||
|
}}
|
||||||
|
placeholder="http://localhost:5173"
|
||||||
|
spellCheck={false}
|
||||||
|
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
{trimmed !== "" && !valid && (
|
||||||
|
<span className="mt-1 block text-xs text-[var(--error)]">
|
||||||
|
Only http:// and https:// URLs can be opened.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((p) => {
|
||||||
|
const active = p.width === width && p.height === height;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => {
|
||||||
|
setWidth(p.width);
|
||||||
|
setHeight(p.height);
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
|
||||||
|
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="Viewport width"
|
||||||
|
value={width}
|
||||||
|
min={200}
|
||||||
|
onChange={(e) => setWidth(Number(e.target.value))}
|
||||||
|
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="Viewport height"
|
||||||
|
value={height}
|
||||||
|
min={200}
|
||||||
|
onChange={(e) => setHeight(Number(e.target.value))}
|
||||||
|
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import PermissionModeControl, {
|
|||||||
permissionModePatch,
|
permissionModePatch,
|
||||||
} from "../PermissionModeControl";
|
} from "../PermissionModeControl";
|
||||||
import CapabilityTiles from "./CapabilityTiles";
|
import CapabilityTiles from "./CapabilityTiles";
|
||||||
|
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
import SaveIndicator from "../../ui/SaveIndicator";
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import { formatAge } from "./format";
|
import { formatAge } from "./format";
|
||||||
@@ -21,6 +23,7 @@ const BACKEND_LABEL: Record<Project["backend"], string> = {
|
|||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
bedrock: "AWS Bedrock",
|
bedrock: "AWS Bedrock",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
|
llama_cpp: "llama.cpp",
|
||||||
open_ai_compatible: "OpenAI Compatible",
|
open_ai_compatible: "OpenAI Compatible",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +33,11 @@ interface Props {
|
|||||||
saveState: SaveState;
|
saveState: SaveState;
|
||||||
actions: ReturnType<typeof useProjectActions>;
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
onOpenTab: (tab: ProjectHomeTabId) => void;
|
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||||
|
/** Base-image staleness, run state and report. Owned by `ProjectHome`. */
|
||||||
|
migration: ContainerMigration;
|
||||||
|
/** Migration mirrors Reset's gate: only offered on a stopped container. */
|
||||||
|
canMigrate: boolean;
|
||||||
|
onOpenMigration: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewTab({
|
export default function OverviewTab({
|
||||||
@@ -38,6 +46,9 @@ export default function OverviewTab({
|
|||||||
saveState,
|
saveState,
|
||||||
actions,
|
actions,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
|
migration,
|
||||||
|
canMigrate,
|
||||||
|
onOpenMigration,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
@@ -109,6 +120,15 @@ export default function OverviewTab({
|
|||||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{/* Only when granted. It is off for nearly every project and an
|
||||||
|
always-present "VPN OFF" would be noise, but where it *is* on the
|
||||||
|
container holds NET_ADMIN, which is worth seeing at a glance. */}
|
||||||
|
{project.vpn_support_enabled && (
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
VPN support{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">ON</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenTab("config")}
|
onClick={() => onOpenTab("config")}
|
||||||
@@ -119,6 +139,14 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* A container missing socat and bwrap is a capability statement, so the
|
||||||
|
out-of-date warning sits directly above the capability inventory. */}
|
||||||
|
<ContainerMigrationBanner
|
||||||
|
migration={migration}
|
||||||
|
canMigrate={canMigrate}
|
||||||
|
onOpen={onOpenMigration}
|
||||||
|
/>
|
||||||
|
|
||||||
<CapabilityTiles
|
<CapabilityTiles
|
||||||
project={project}
|
project={project}
|
||||||
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { useAppState } from "../../../store/appState";
|
|||||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||||
|
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import OverflowMenu from "../../ui/OverflowMenu";
|
import OverflowMenu from "../../ui/OverflowMenu";
|
||||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||||
import ConfirmResetModal from "../ConfirmResetModal";
|
import ConfirmResetModal from "../ConfirmResetModal";
|
||||||
|
import MigrateContainerModal from "../MigrateContainerModal";
|
||||||
import OverviewTab from "./OverviewTab";
|
import OverviewTab from "./OverviewTab";
|
||||||
import SessionsTab from "./SessionsTab";
|
import SessionsTab from "./SessionsTab";
|
||||||
import AutomationTab from "./AutomationTab";
|
import AutomationTab from "./AutomationTab";
|
||||||
import ConfigTab from "./ConfigTab";
|
import ConfigTab from "./ConfigTab";
|
||||||
import FilesTab from "./FilesTab";
|
import FilesTab from "./FilesTab";
|
||||||
|
import BrowserTab from "./BrowserTab";
|
||||||
import { formatUptime } from "./format";
|
import { formatUptime } from "./format";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -22,6 +25,7 @@ const TABS = [
|
|||||||
{ id: "automation", label: "Automation" },
|
{ id: "automation", label: "Automation" },
|
||||||
{ id: "config", label: "Config" },
|
{ id: "config", label: "Config" },
|
||||||
{ id: "files", label: "Files" },
|
{ id: "files", label: "Files" },
|
||||||
|
{ id: "browser", label: "Browser" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||||
@@ -39,8 +43,19 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const { projects, remove } = useProjects();
|
const { projects, remove } = useProjects();
|
||||||
const project = projects.find((p) => p.id === projectId);
|
const project = projects.find((p) => p.id === projectId);
|
||||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||||
|
|
||||||
|
// Somewhere else asked for this project on a particular sub-tab — currently
|
||||||
|
// "I opened a page in the container's browser, show me it". Consumed once, so
|
||||||
|
// it cannot fight the user's own clicking afterwards.
|
||||||
|
const pendingHomeTab = useAppState((s) => s.pendingHomeTab);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingHomeTab?.projectId !== projectId) return;
|
||||||
|
setTab(pendingHomeTab.tab as ProjectHomeTabId);
|
||||||
|
useAppState.getState().clearPendingHomeTab();
|
||||||
|
}, [pendingHomeTab, projectId]);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
const { runningSince, progress } = useAppState(
|
const { runningSince, progress } = useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
runningSince: s.runningSince[projectId],
|
runningSince: s.runningSince[projectId],
|
||||||
@@ -62,6 +77,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const { save, saveState } = useProjectSave(
|
const { save, saveState } = useProjectSave(
|
||||||
project ?? ({ id: projectId, name: "" } as never),
|
project ?? ({ id: projectId, name: "" } as never),
|
||||||
);
|
);
|
||||||
|
// Owned here, not in the modal: the run outlives the dialog, and the Overview
|
||||||
|
// banner has to keep showing progress and the report after it is dismissed.
|
||||||
|
const migration = useContainerMigration(
|
||||||
|
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||||
|
);
|
||||||
|
|
||||||
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||||
|
|
||||||
@@ -79,6 +99,22 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
// Rebuilding on a new base swaps the container out, so it gates exactly like
|
||||||
|
// Reset does — with the extra condition that there is a container to migrate.
|
||||||
|
// An interrupted migration is excluded too: its action is Resume, on the
|
||||||
|
// Overview banner, not a fresh pre-flight.
|
||||||
|
//
|
||||||
|
// `probeSettled` is the fourth condition and it is not cosmetic. The probe
|
||||||
|
// takes ~6 s, and until it lands every delta the pre-flight renders reads as
|
||||||
|
// empty — so the dialog would tell the user there was nothing to copy while
|
||||||
|
// the backend was told not to copy anything.
|
||||||
|
const canMigrate =
|
||||||
|
isStopped &&
|
||||||
|
!actions.busy &&
|
||||||
|
!migration.running &&
|
||||||
|
!migration.interrupted &&
|
||||||
|
migration.probeSettled &&
|
||||||
|
!!project.container_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||||
@@ -145,6 +181,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
onSelect: actions.handleBackup,
|
onSelect: actions.handleBackup,
|
||||||
disabled: actions.backingUp || !project.container_id,
|
disabled: actions.backingUp || !project.container_id,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Update container base…",
|
||||||
|
onSelect: () => setShowMigration(true),
|
||||||
|
disabled: !canMigrate,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Reset container…",
|
label: "Reset container…",
|
||||||
onSelect: () => setConfirmReset(true),
|
onSelect: () => setConfirmReset(true),
|
||||||
@@ -198,6 +239,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
saveState={saveState}
|
saveState={saveState}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onOpenTab={setTab}
|
onOpenTab={setTab}
|
||||||
|
migration={migration}
|
||||||
|
canMigrate={canMigrate}
|
||||||
|
onOpenMigration={() => setShowMigration(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||||
@@ -206,8 +250,21 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
<ConfigTab project={project} save={save} saveState={saveState} />
|
<ConfigTab project={project} save={save} saveState={saveState} />
|
||||||
)}
|
)}
|
||||||
{tab === "files" && <FilesTab project={project} />}
|
{tab === "files" && <FilesTab project={project} />}
|
||||||
|
{tab === "browser" && (
|
||||||
|
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showMigration && (
|
||||||
|
<MigrateContainerModal
|
||||||
|
projectName={project.name}
|
||||||
|
staleness={migration.staleness}
|
||||||
|
migration={migration}
|
||||||
|
// Closing is not cancelling — the run keeps going and the Overview
|
||||||
|
// banner keeps reporting it.
|
||||||
|
onClose={() => setShowMigration(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{confirmReset && (
|
{confirmReset && (
|
||||||
<ConfirmResetModal
|
<ConfirmResetModal
|
||||||
projectName={project.name}
|
projectName={project.name}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
|
|||||||
created_at: null,
|
created_at: null,
|
||||||
last_run: null,
|
last_run: null,
|
||||||
next_run: null,
|
next_run: null,
|
||||||
|
running: false,
|
||||||
|
running_since: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
|||||||
import type { Project } from "../../../../lib/types";
|
import type { Project } from "../../../../lib/types";
|
||||||
import Button from "../../../ui/Button";
|
import Button from "../../../ui/Button";
|
||||||
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
||||||
|
import CaCertPathInput from "../../../settings/CaCertPathInput";
|
||||||
import EnvVarsEditor from "../../EnvVarsEditor";
|
import EnvVarsEditor from "../../EnvVarsEditor";
|
||||||
import PortMappingsEditor from "../../PortMappingsEditor";
|
import PortMappingsEditor from "../../PortMappingsEditor";
|
||||||
|
|
||||||
@@ -20,12 +21,14 @@ export default function AccessSection({
|
|||||||
disabledReason,
|
disabledReason,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
||||||
|
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
|
||||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSshKeyPath(project.ssh_key_path ?? "");
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
|
setCaCertPath(project.ca_cert_path ?? "");
|
||||||
setGitName(project.git_user_name ?? "");
|
setGitName(project.git_user_name ?? "");
|
||||||
setGitEmail(project.git_user_email ?? "");
|
setGitEmail(project.git_user_email ?? "");
|
||||||
setGitToken(project.git_token ?? "");
|
setGitToken(project.git_token ?? "");
|
||||||
@@ -114,6 +117,24 @@ export default function AccessSection({
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Corporate CA certificate"
|
||||||
|
hint="Overrides the global certificate for this project only. A certificate file, or a folder of them, trusted inside the container by curl, git, npm, pip, Chromium and Claude Code."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<CaCertPathInput
|
||||||
|
id={id}
|
||||||
|
value={caCertPath}
|
||||||
|
onChange={setCaCertPath}
|
||||||
|
onCommit={(value) => save({ ca_cert_path: value.trim() || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="/etc/ssl/certs/corp-root.pem"
|
||||||
|
emptyHint="Using the global certificate from Settings → Certificates."
|
||||||
|
inputClassName={`${inputClass} min-w-0`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
Environment variables
|
Environment variables
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import ModelSection from "./ModelSection";
|
import ModelSection, {
|
||||||
|
DEFAULT_LLAMACPP_CONFIG,
|
||||||
|
DEFAULT_OLLAMA_CONFIG,
|
||||||
|
} from "./ModelSection";
|
||||||
|
import { CUSTOM_ENDPOINT_BACKENDS } from "../../../../lib/types";
|
||||||
import type { Backend, Project } from "../../../../lib/types";
|
import type { Backend, Project } from "../../../../lib/types";
|
||||||
|
|
||||||
const baseProject: Project = {
|
const baseProject: Project = {
|
||||||
@@ -12,6 +16,7 @@ const baseProject: Project = {
|
|||||||
backend: "anthropic",
|
backend: "anthropic",
|
||||||
bedrock_config: null,
|
bedrock_config: null,
|
||||||
ollama_config: null,
|
ollama_config: null,
|
||||||
|
llamacpp_config: null,
|
||||||
openai_compatible_config: null,
|
openai_compatible_config: null,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: true,
|
sandbox_mode_enabled: true,
|
||||||
@@ -55,7 +60,7 @@ describe("ModelSection — shared auth token toggle", () => {
|
|||||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
|
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each<Backend>(["bedrock", "ollama", "open_ai_compatible"])(
|
it.each<Backend>(["bedrock", "ollama", "llama_cpp", "open_ai_compatible"])(
|
||||||
"is hidden for the %s backend",
|
"is hidden for the %s backend",
|
||||||
(backend) => {
|
(backend) => {
|
||||||
renderSection({ backend });
|
renderSection({ backend });
|
||||||
@@ -93,3 +98,118 @@ describe("ModelSection — shared auth token toggle", () => {
|
|||||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
|
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ModelSection — llama.cpp backend", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("is offered as a backend choice", () => {
|
||||||
|
renderSection();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("option", { name: "llama.cpp" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds llama-server's default port when the backend is first chosen", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||||
|
target: { value: "llama_cpp" },
|
||||||
|
});
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
backend: "llama_cpp",
|
||||||
|
llamacpp_config: DEFAULT_LLAMACPP_CONFIG,
|
||||||
|
});
|
||||||
|
expect(DEFAULT_LLAMACPP_CONFIG.base_url).toContain(":8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clobber an existing config when re-selected", () => {
|
||||||
|
renderSection({
|
||||||
|
backend: "ollama",
|
||||||
|
llamacpp_config: {
|
||||||
|
base_url: "http://gpu-box:9090",
|
||||||
|
model_id: "mine",
|
||||||
|
haiku_model_id: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||||
|
target: { value: "llama_cpp" },
|
||||||
|
});
|
||||||
|
expect(save).toHaveBeenCalledWith({ backend: "llama_cpp" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the base URL and model on blur", () => {
|
||||||
|
renderSection({ backend: "llama_cpp" });
|
||||||
|
|
||||||
|
const url = screen.getByLabelText("Base URL");
|
||||||
|
fireEvent.change(url, { target: { value: "http://gpu-box:8080" } });
|
||||||
|
fireEvent.blur(url);
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, base_url: "http://gpu-box:8080" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = screen.getByLabelText("Model");
|
||||||
|
fireEvent.change(model, { target: { value: "qwen3.5-coder-30b" } });
|
||||||
|
fireEvent.blur(model);
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, model_id: "qwen3.5-coder-30b" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModelSection — background (haiku) model override", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("covers exactly the backends that point at a custom endpoint", () => {
|
||||||
|
expect([...CUSTOM_ENDPOINT_BACKENDS]).toEqual([
|
||||||
|
"ollama",
|
||||||
|
"llama_cpp",
|
||||||
|
"open_ai_compatible",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([...CUSTOM_ENDPOINT_BACKENDS])("is offered for the %s backend", (backend) => {
|
||||||
|
renderSection({ backend });
|
||||||
|
const field = screen.getByLabelText("Background model");
|
||||||
|
expect(field).toBeInTheDocument();
|
||||||
|
// Blank is the documented default — it reuses the main model.
|
||||||
|
expect(field).toHaveValue("");
|
||||||
|
expect(field).toHaveAttribute("placeholder", "(same as the model above)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Backend>(["anthropic", "bedrock"])(
|
||||||
|
"is not offered for the %s backend, which keeps Claude Code's defaults",
|
||||||
|
(backend) => {
|
||||||
|
renderSection({ backend });
|
||||||
|
expect(screen.queryByLabelText("Background model")).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("saves a trimmed override, and clears it back to null when blanked", () => {
|
||||||
|
renderSection({ backend: "ollama" });
|
||||||
|
const field = screen.getByLabelText("Background model");
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: " qwen3.5:3b " } });
|
||||||
|
fireEvent.blur(field);
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: "qwen3.5:3b" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: " " } });
|
||||||
|
fireEvent.blur(field);
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an existing override and explains what it is for", () => {
|
||||||
|
renderSection({
|
||||||
|
backend: "llama_cpp",
|
||||||
|
llamacpp_config: {
|
||||||
|
base_url: "http://host.docker.internal:8080",
|
||||||
|
model_id: "big",
|
||||||
|
haiku_model_id: "small",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText("Background model")).toHaveValue("small");
|
||||||
|
expect(screen.getByText(/background work/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Backend,
|
Backend,
|
||||||
BedrockAuthMethod,
|
BedrockAuthMethod,
|
||||||
BedrockConfig,
|
BedrockConfig,
|
||||||
|
LlamaCppConfig,
|
||||||
OllamaConfig,
|
OllamaConfig,
|
||||||
OpenAiCompatibleConfig,
|
OpenAiCompatibleConfig,
|
||||||
Project,
|
Project,
|
||||||
@@ -31,14 +32,28 @@ export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
|||||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||||
base_url: "http://host.docker.internal:11434",
|
base_url: "http://host.docker.internal:11434",
|
||||||
model_id: null,
|
model_id: null,
|
||||||
|
haiku_model_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `llama-server` listens on port 8080 unless `--port` says otherwise. */
|
||||||
|
export const DEFAULT_LLAMACPP_CONFIG: LlamaCppConfig = {
|
||||||
|
base_url: "http://host.docker.internal:8080",
|
||||||
|
model_id: null,
|
||||||
|
haiku_model_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||||
base_url: "http://host.docker.internal:4000",
|
base_url: "http://host.docker.internal:4000",
|
||||||
api_key: null,
|
api_key: null,
|
||||||
model_id: null,
|
model_id: null,
|
||||||
|
haiku_model_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Shown under the optional per-backend Haiku override. Kept in one place so
|
||||||
|
* all three custom-endpoint backends explain it identically. */
|
||||||
|
const HAIKU_HINT =
|
||||||
|
"Optional. Claude Code resolves the `haiku` alias to this, and uses it for background work such as conversation titles. Leave blank to reuse the model above — that is what stops background calls failing against a server that only serves one model.";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
@@ -64,6 +79,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
const [ollamaModelId, setOllamaModelId] = useState(
|
const [ollamaModelId, setOllamaModelId] = useState(
|
||||||
project.ollama_config?.model_id ?? "",
|
project.ollama_config?.model_id ?? "",
|
||||||
);
|
);
|
||||||
|
const [ollamaHaikuModelId, setOllamaHaikuModelId] = useState(
|
||||||
|
project.ollama_config?.haiku_model_id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [llamaCppBaseUrl, setLlamaCppBaseUrl] = useState(
|
||||||
|
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||||
|
);
|
||||||
|
const [llamaCppModelId, setLlamaCppModelId] = useState(
|
||||||
|
project.llamacpp_config?.model_id ?? "",
|
||||||
|
);
|
||||||
|
const [llamaCppHaikuModelId, setLlamaCppHaikuModelId] = useState(
|
||||||
|
project.llamacpp_config?.haiku_model_id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||||
project.openai_compatible_config?.base_url ??
|
project.openai_compatible_config?.base_url ??
|
||||||
@@ -75,6 +103,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
const [oaiModelId, setOaiModelId] = useState(
|
const [oaiModelId, setOaiModelId] = useState(
|
||||||
project.openai_compatible_config?.model_id ?? "",
|
project.openai_compatible_config?.model_id ?? "",
|
||||||
);
|
);
|
||||||
|
const [oaiHaikuModelId, setOaiHaikuModelId] = useState(
|
||||||
|
project.openai_compatible_config?.haiku_model_id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||||
@@ -88,12 +119,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
setServiceTier(bc.service_tier ?? "");
|
setServiceTier(bc.service_tier ?? "");
|
||||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||||
|
setOllamaHaikuModelId(project.ollama_config?.haiku_model_id ?? "");
|
||||||
|
setLlamaCppBaseUrl(
|
||||||
|
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||||
|
);
|
||||||
|
setLlamaCppModelId(project.llamacpp_config?.model_id ?? "");
|
||||||
|
setLlamaCppHaikuModelId(project.llamacpp_config?.haiku_model_id ?? "");
|
||||||
setOaiBaseUrl(
|
setOaiBaseUrl(
|
||||||
project.openai_compatible_config?.base_url ??
|
project.openai_compatible_config?.base_url ??
|
||||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||||
);
|
);
|
||||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||||
|
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||||
@@ -104,6 +142,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveLlamaCpp = (patch: Partial<LlamaCppConfig>) =>
|
||||||
|
save({
|
||||||
|
llamacpp_config: {
|
||||||
|
...(project.llamacpp_config ?? DEFAULT_LLAMACPP_CONFIG),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||||
save({
|
save({
|
||||||
openai_compatible_config: {
|
openai_compatible_config: {
|
||||||
@@ -122,6 +168,8 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||||
if (mode === "ollama" && !project.ollama_config)
|
if (mode === "ollama" && !project.ollama_config)
|
||||||
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||||
|
if (mode === "llama_cpp" && !project.llamacpp_config)
|
||||||
|
patch.llamacpp_config = DEFAULT_LLAMACPP_CONFIG;
|
||||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||||
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||||
save(patch);
|
save(patch);
|
||||||
@@ -131,7 +179,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||||
<Field
|
<Field
|
||||||
label="Backend"
|
label="Backend"
|
||||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama, llama.cpp and OpenAI Compatible point at any endpoint that implements the Anthropic Messages API."
|
||||||
>
|
>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<select
|
<select
|
||||||
@@ -144,6 +192,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="bedrock">Bedrock</option>
|
<option value="bedrock">Bedrock</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="llama_cpp">llama.cpp</option>
|
||||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
@@ -365,6 +414,73 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Background model" hint={HAIKU_HINT}>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={ollamaHaikuModelId}
|
||||||
|
onChange={(e) => setOllamaHaikuModelId(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
saveOllama({ haiku_model_id: ollamaHaikuModelId.trim() || null })
|
||||||
|
}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.backend === "llama_cpp" && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<Field
|
||||||
|
label="Base URL"
|
||||||
|
hint="Your llama-server. It listens on port 8080 by default; use host.docker.internal to reach the host machine."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={llamaCppBaseUrl}
|
||||||
|
onChange={(e) => setLlamaCppBaseUrl(e.target.value)}
|
||||||
|
onBlur={() => saveLlamaCpp({ base_url: llamaCppBaseUrl })}
|
||||||
|
placeholder="http://host.docker.internal:8080"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Model"
|
||||||
|
hint="The model llama-server was started with. llama-server serves one model, so this is mainly what Claude Code reports — but it is also what the model aliases are pinned to."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={llamaCppModelId}
|
||||||
|
onChange={(e) => setLlamaCppModelId(e.target.value)}
|
||||||
|
onBlur={() => saveLlamaCpp({ model_id: llamaCppModelId || null })}
|
||||||
|
placeholder="qwen3.5-coder-30b"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Background model" hint={HAIKU_HINT}>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={llamaCppHaikuModelId}
|
||||||
|
onChange={(e) => setLlamaCppHaikuModelId(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
saveLlamaCpp({ haiku_model_id: llamaCppHaikuModelId.trim() || null })
|
||||||
|
}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -372,7 +488,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||||
<Field
|
<Field
|
||||||
label="Base URL"
|
label="Base URL"
|
||||||
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
hint="A gateway that implements the Anthropic Messages API (POST /v1/messages) — LiteLLM, for example. An endpoint that only speaks OpenAI /v1/chat/completions will not work."
|
||||||
>
|
>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
@@ -413,6 +529,21 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Background model" hint={HAIKU_HINT}>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={oaiHaikuModelId}
|
||||||
|
onChange={(e) => setOaiHaikuModelId(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
saveOpenAi({ haiku_model_id: oaiHaikuModelId.trim() || null })
|
||||||
|
}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ConfigGroup>
|
</ConfigGroup>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import RuntimeSection from "./RuntimeSection";
|
||||||
|
import type { Project } from "../../../../lib/types";
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
llamacpp_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
ca_cert_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VPN = "VPN support";
|
||||||
|
|
||||||
|
const save = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||||
|
return render(
|
||||||
|
<RuntimeSection
|
||||||
|
project={{ ...baseProject, ...over }}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason="Container must be stopped to change this setting."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RuntimeSection — VPN support toggle", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("saves only the VPN flag when switched on", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||||
|
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the flag off again, rather than dropping the key", () => {
|
||||||
|
// Off has to be written explicitly: the container carries a
|
||||||
|
// `triple-c.vpn-support` label either way, and an absent value would leave
|
||||||
|
// the capability granted.
|
||||||
|
renderSection({ vpn_support_enabled: true });
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||||
|
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects the project's current state", () => {
|
||||||
|
renderSection({ vpn_support_enabled: true });
|
||||||
|
expect(screen.getByRole("switch", { name: VPN })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be changed while the container is running", () => {
|
||||||
|
// Capabilities and devices are fixed at creation, so this setting is gated
|
||||||
|
// on the container being stopped along with the rest of the tab.
|
||||||
|
renderSection({}, true);
|
||||||
|
const toggle = screen.getByRole("switch", { name: VPN });
|
||||||
|
expect(toggle).toBeDisabled();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns that the change recreates the container", () => {
|
||||||
|
renderSection();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/recreates the container on its next start/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,19 @@ export default function RuntimeSection({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="VPN support"
|
||||||
|
hint="Grants NET_ADMIN and the /dev/net/tun device so a VPN client (PIA, WireGuard, OpenVPN) can build a tunnel inside the container. Without it a client installs and runs but its connection hangs until it times out. Anything in the container can then reconfigure the container's own network stack; the host's is untouched. Changing this recreates the container on its next start — the home and .claude volumes are preserved."
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="VPN support"
|
||||||
|
checked={project.vpn_support_enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => save({ vpn_support_enabled: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="Mission Control"
|
label="Mission Control"
|
||||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export function formatElapsed(ms: number): string {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "for 42s" / "for 4m" / "for 1h 12m" — elapsed phrasing for a run in flight.
|
||||||
|
* Seconds are kept below a minute because the first thing anyone wants from a
|
||||||
|
* freshly triggered run is evidence that it started at all. */
|
||||||
|
export function formatRunningFor(iso: string | null | undefined): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const started = Date.parse(iso);
|
||||||
|
if (Number.isNaN(started)) return null;
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
|
||||||
|
if (seconds < 60) return `for ${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `for ${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `for ${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Uptime phrasing for a known start timestamp. */
|
/** Uptime phrasing for a known start timestamp. */
|
||||||
export function formatUptime(startedAtMs: number | undefined): string | null {
|
export function formatUptime(startedAtMs: number | undefined): string | null {
|
||||||
if (startedAtMs === undefined) return null;
|
if (startedAtMs === undefined) return null;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Shared wording for container base-image migration.
|
||||||
|
*
|
||||||
|
* The banner, the pre-flight modal and the report all have to make the same
|
||||||
|
* promise about what survives, or the feature reads as another Reset. It is
|
||||||
|
* written once here so the three surfaces cannot drift apart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PackageFailure } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||||
|
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||||||
|
*/
|
||||||
|
export const KEPT_AUTOMATICALLY = [
|
||||||
|
"Your claude login and ~/.claude.json — no signing in again",
|
||||||
|
"Skills, agents, commands, hooks, plugins and MCP config",
|
||||||
|
"Every saved session transcript, so past sessions still resume",
|
||||||
|
"Scheduler tasks and their logs",
|
||||||
|
"SSH keys, git config and shell history",
|
||||||
|
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const KEPT_WHY =
|
||||||
|
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The honest list of what the writable layer holds, because the modal's own
|
||||||
|
* sections name more than one thing and copy that says "the only thing" while
|
||||||
|
* the section below it offers to copy files is copy the user cannot trust.
|
||||||
|
*/
|
||||||
|
export const LOST_WITHOUT_REPLAY =
|
||||||
|
"What a new base does not carry over is what lives in the container itself: system packages you installed with apt, global npm packages, and files under /usr/local, /opt, /srv or loose in /workspace. This update puts those back.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exception, and it is not a small one — so it gets its own line wherever
|
||||||
|
* the update is offered. Reinstalling `postgresql` gets the package back and an
|
||||||
|
* empty cluster with it; the ordinary Reset-free recreate keeps /var because it
|
||||||
|
* builds from the project's own saved image, so this is the one way in which
|
||||||
|
* updating the base is more destructive than leaving it alone.
|
||||||
|
*/
|
||||||
|
export const DATA_NOT_CARRIED =
|
||||||
|
"Data written under /var is not carried across and reinstalling the package does not bring it back — a database in /var/lib, a site in /var/www. Back it up from inside the container before you update.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||||||
|
* it swaps the system layer back and leaves both volumes exactly where the
|
||||||
|
* migrated session left them.
|
||||||
|
*/
|
||||||
|
export const ROLLBACK_SCOPE =
|
||||||
|
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||||||
|
|
||||||
|
export const ROLLBACK_DISK_COST =
|
||||||
|
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||||||
|
|
||||||
|
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||||||
|
export const MID_RUN_SAFETY =
|
||||||
|
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||||||
|
|
||||||
|
export const REPLAY_COST =
|
||||||
|
"Needs network access and usually takes 1–2 minutes.";
|
||||||
|
|
||||||
|
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
|
||||||
|
export function formatDataSize(bytes: number): string {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1000 && unit < units.length - 1) {
|
||||||
|
value /= 1000;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||||
|
export function formatSnapshotDate(iso: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return null;
|
||||||
|
return new Date(ms).toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||||||
|
export function joinFeatures(features: string[]): string {
|
||||||
|
if (features.length === 0) return "";
|
||||||
|
if (features.length === 1) return features[0];
|
||||||
|
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||||||
|
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||||||
|
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text form of a partial report, for the copy button. */
|
||||||
|
export function failureReportText(failures: PackageFailure[]): string {
|
||||||
|
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
|
||||||
|
return [
|
||||||
|
"Packages that could not be reinstalled:",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
aptRetryCommand(failures),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import CaCertPathInput from "./CaCertPathInput";
|
||||||
|
import type { CaCertInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
const inspectCaCertPath = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
inspectCaCertPath: (path: string) => inspectCaCertPath(path),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openDialog = vi.fn();
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: (opts: unknown) => openDialog(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const info = (over: Partial<CaCertInfo> = {}): CaCertInfo => ({
|
||||||
|
exists: true,
|
||||||
|
is_directory: false,
|
||||||
|
cert_count: 1,
|
||||||
|
installed_names: ["corp-root.crt"],
|
||||||
|
error: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderInput(value = "", over: Partial<Parameters<typeof CaCertPathInput>[0]> = {}) {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const utils = render(
|
||||||
|
<CaCertPathInput
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onCommit={onCommit}
|
||||||
|
inputClassName="input"
|
||||||
|
{...over}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onChange, onCommit, ...utils };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CaCertPathInput", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
inspectCaCertPath.mockResolvedValue(info());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inspect anything while the path is empty", async () => {
|
||||||
|
renderInput("");
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
expect(inspectCaCertPath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty hint instead of a status when unset", () => {
|
||||||
|
renderInput("", { emptyHint: "Using the global certificate." });
|
||||||
|
expect(screen.getByText("Using the global certificate.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the certificate count and the names they are installed as", async () => {
|
||||||
|
// The rename is the whole point: update-ca-certificates ignores a .pem.
|
||||||
|
inspectCaCertPath.mockResolvedValue(
|
||||||
|
info({ cert_count: 2, installed_names: ["corp-root.crt", "corp-intermediate.crt"] }),
|
||||||
|
);
|
||||||
|
renderInput("/certs");
|
||||||
|
await waitFor(() => expect(screen.getByText(/Found 2 certificates/)).toBeTruthy());
|
||||||
|
expect(screen.getByText(/corp-root\.crt, corp-intermediate\.crt/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the singular for one certificate", async () => {
|
||||||
|
renderInput("/certs/corp.pem");
|
||||||
|
await waitFor(() => expect(screen.getByText(/Found 1 certificate$|Found 1 certificate/)).toBeTruthy());
|
||||||
|
expect(screen.queryByText(/Found 1 certificates/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an unusable path inline rather than silently accepting it", async () => {
|
||||||
|
inspectCaCertPath.mockResolvedValue(
|
||||||
|
info({ exists: false, cert_count: 0, installed_names: [], error: "path does not exist" }),
|
||||||
|
);
|
||||||
|
renderInput("/gone");
|
||||||
|
await waitFor(() => expect(screen.getByText(/path does not exist/)).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits on blur", () => {
|
||||||
|
const { onCommit } = renderInput("/certs");
|
||||||
|
fireEvent.blur(screen.getByRole("textbox"));
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("/certs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers both a file and a folder picker, because the setting accepts either", async () => {
|
||||||
|
openDialog.mockResolvedValue("/picked/corp.pem");
|
||||||
|
const { onChange, onCommit } = renderInput("");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("File…"));
|
||||||
|
await waitFor(() => expect(onCommit).toHaveBeenCalledWith("/picked/corp.pem"));
|
||||||
|
expect(openDialog).toHaveBeenCalledWith({ directory: false, multiple: false });
|
||||||
|
|
||||||
|
openDialog.mockResolvedValue("/picked/certs");
|
||||||
|
fireEvent.click(screen.getByText("Folder…"));
|
||||||
|
await waitFor(() => expect(openDialog).toHaveBeenLastCalledWith({ directory: true, multiple: false }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("/picked/certs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not commit when the picker is dismissed", async () => {
|
||||||
|
openDialog.mockResolvedValue(null);
|
||||||
|
const { onCommit } = renderInput("");
|
||||||
|
fireEvent.click(screen.getByText("Folder…"));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the inputs when the container is running", () => {
|
||||||
|
renderInput("/certs", { disabled: true });
|
||||||
|
expect((screen.getByRole("textbox") as HTMLInputElement).disabled).toBe(true);
|
||||||
|
for (const label of ["File…", "Folder…"]) {
|
||||||
|
expect((screen.getByText(label) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { inspectCaCertPath } from "../../lib/tauri-commands";
|
||||||
|
import type { CaCertInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Wired to the calling `Field`'s label, where there is one. */
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Persist the value — called on blur and immediately after a Browse. */
|
||||||
|
onCommit: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Shown in place of the status line while the field is empty. */
|
||||||
|
emptyHint?: string;
|
||||||
|
/** Tailwind classes for the text input, so each caller keeps its local
|
||||||
|
* convention (the host settings panel and the project Config tab do not
|
||||||
|
* style their inputs the same way). */
|
||||||
|
inputClassName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path field for a corporate CA certificate — a single file *or* a directory
|
||||||
|
* of them — shared by the global setting and the per-project override.
|
||||||
|
*
|
||||||
|
* Two Browse buttons rather than one: the platform file dialog cannot offer
|
||||||
|
* "a file or a folder" in a single call, and which one the user wants is not
|
||||||
|
* guessable (a lone `corp-root.pem` is as common as a folder of chained certs).
|
||||||
|
*
|
||||||
|
* The status line is what makes the feature debuggable. It reports the
|
||||||
|
* certificate count and, crucially, the `.crt` names each file is installed
|
||||||
|
* as: `update-ca-certificates` matches `*.crt` case-sensitively and ignores a
|
||||||
|
* `.pem` in complete silence, so seeing `corp-root.pem → corp-root.crt` is the
|
||||||
|
* difference between trusting the setting and guessing at it.
|
||||||
|
*/
|
||||||
|
export default function CaCertPathInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
disabled = false,
|
||||||
|
placeholder,
|
||||||
|
emptyHint,
|
||||||
|
inputClassName,
|
||||||
|
}: Props) {
|
||||||
|
const [info, setInfo] = useState<CaCertInfo | null>(null);
|
||||||
|
// Guards against a slow inspect for an earlier value landing after a newer
|
||||||
|
// one and describing the wrong path.
|
||||||
|
const requestId = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = ++requestId.current;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
inspectCaCertPath(trimmed)
|
||||||
|
.then((result) => {
|
||||||
|
if (requestId.current === id) setInfo(result);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestId.current === id) setInfo(null);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const browse = async (directory: boolean) => {
|
||||||
|
const selected = await open({ directory, multiple: false });
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
onChange(selected);
|
||||||
|
onCommit(selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={() => onCommit(value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<Button size="md" disabled={disabled} onClick={() => browse(false)}>
|
||||||
|
File…
|
||||||
|
</Button>
|
||||||
|
<Button size="md" disabled={disabled} onClick={() => browse(true)}>
|
||||||
|
Folder…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CaCertStatus value={value} info={info} emptyHint={emptyHint} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaCertStatus({
|
||||||
|
value,
|
||||||
|
info,
|
||||||
|
emptyHint,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
info: CaCertInfo | null;
|
||||||
|
emptyHint?: string;
|
||||||
|
}) {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return emptyHint ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">{emptyHint}</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
if (info.error) {
|
||||||
|
// Glyph + word, never colour alone.
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-[var(--error)]" role="status">
|
||||||
|
<span aria-hidden="true">✕ </span>
|
||||||
|
Problem: {info.error}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (info.cert_count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-[var(--success)]" role="status">
|
||||||
|
<span aria-hidden="true">✓ </span>
|
||||||
|
Found {info.cert_count} certificate{info.cert_count === 1 ? "" : "s"}
|
||||||
|
{info.installed_names.length > 0 && (
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{" "}
|
||||||
|
— installed as {info.installed_names.slice(0, 4).join(", ")}
|
||||||
|
{info.installed_names.length > 4
|
||||||
|
? ` and ${info.installed_names.length - 4} more`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import CaCertPathInput from "./CaCertPathInput";
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
"flex-1 min-w-0 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global corporate CA certificate setting.
|
||||||
|
*
|
||||||
|
* Applies to every project unless one overrides it in Project Home → Config →
|
||||||
|
* Access. Changing it recreates each container on its next start — the
|
||||||
|
* certificate is copied into the container's trust store once, at start, so
|
||||||
|
* there is nowhere else for a change to land.
|
||||||
|
*/
|
||||||
|
export default function CertificateSettings() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const [path, setPath] = useState(appSettings?.ca_cert_path ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPath(appSettings?.ca_cert_path ?? "");
|
||||||
|
}, [appSettings?.ca_cert_path]);
|
||||||
|
|
||||||
|
const commit = async (value: string) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
const next = value.trim() || null;
|
||||||
|
if (next === appSettings.ca_cert_path) return;
|
||||||
|
await saveSettings({ ...appSettings, ca_cert_path: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
htmlFor="global-ca-cert-path"
|
||||||
|
>
|
||||||
|
Corporate CA Certificate
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
A certificate file, or a folder of them, for organisations whose network
|
||||||
|
inspects TLS. Mounted read-only into every container and trusted by
|
||||||
|
curl, git, npm, pip, Chromium and Claude Code itself. Per-project
|
||||||
|
settings override this; changing it recreates containers on next start.
|
||||||
|
</p>
|
||||||
|
<CaCertPathInput
|
||||||
|
id="global-ca-cert-path"
|
||||||
|
value={path}
|
||||||
|
onChange={setPath}
|
||||||
|
onCommit={commit}
|
||||||
|
placeholder="/etc/ssl/certs/corp-root.pem"
|
||||||
|
emptyHint="Not set — containers trust only the public CAs shipped with the image."
|
||||||
|
inputClassName={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Every event the hook subscribes to, so the unmount test counts the right
|
||||||
|
* number of teardowns instead of a magic number that drifts. */
|
||||||
|
const EVENT_NAMES = [
|
||||||
|
"claude-token-progress",
|
||||||
|
"claude-token-output",
|
||||||
|
"claude-token-link",
|
||||||
|
"claude-token-code-rejected",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The sign-in URL at its real length (346 characters, measured against
|
||||||
|
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
|
||||||
|
* transcript ever contains. */
|
||||||
|
const FULL_URL =
|
||||||
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||||
|
const TRUNCATED_URL = FULL_URL.slice(0, 80);
|
||||||
|
|
||||||
function emitOutput(chunk: string, projectId = "p1") {
|
function emitOutput(chunk: string, projectId = "p1") {
|
||||||
act(() => {
|
act(() => {
|
||||||
handlers.get("claude-token-output")?.({
|
handlers.get("claude-token-output")?.({
|
||||||
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitLink(url: string, projectId = "p1") {
|
||||||
|
act(() => {
|
||||||
|
handlers.get("claude-token-link")?.({
|
||||||
|
payload: { project_id: projectId, url },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitCodeRejected(message: string, attemptsRemaining: number) {
|
||||||
|
act(() => {
|
||||||
|
handlers.get("claude-token-code-rejected")?.({
|
||||||
|
payload: {
|
||||||
|
project_id: "p1",
|
||||||
|
message,
|
||||||
|
attempts_remaining: attemptsRemaining,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderModal(
|
function renderModal(
|
||||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||||
) {
|
) {
|
||||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
|||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
await flowStarted();
|
await flowStarted();
|
||||||
unmount();
|
unmount();
|
||||||
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
|
await waitFor(() =>
|
||||||
|
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The hyperlink target, not the wrapped display text ────────────────
|
||||||
|
//
|
||||||
|
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
||||||
|
// the terminal width, so the transcript holds five 80-character pieces of a
|
||||||
|
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
||||||
|
// parameter and sends it on `claude-token-link`.
|
||||||
|
|
||||||
|
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
// What the transcript holds: the first slice only.
|
||||||
|
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
||||||
|
// What the hyperlink parameter holds: all of it.
|
||||||
|
emitLink(FULL_URL);
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||||
|
fireEvent.click(link);
|
||||||
|
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||||
|
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
expect(openUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a hyperlink belonging to a different project", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
emitLink(FULL_URL, "p2");
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A refused code is recoverable, not a hang ─────────────────────────
|
||||||
|
|
||||||
|
it("reports a rejected code and lets another one be submitted", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Authentication code");
|
||||||
|
fireEvent.change(input, { target: { value: "truncated" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
||||||
|
);
|
||||||
|
// Before the rejection arrives the UI claims the sign-in is completing.
|
||||||
|
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
||||||
|
|
||||||
|
emitCodeRejected(
|
||||||
|
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reported, not waited out — and the flow is still live.
|
||||||
|
await screen.findByText(/That code was rejected/);
|
||||||
|
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// A second code goes through without restarting the whole flow.
|
||||||
|
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
||||||
|
);
|
||||||
|
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with a reported failure when the retries run out", async () => {
|
||||||
|
acquireClaudeToken.mockRejectedValue(
|
||||||
|
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
||||||
|
);
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const banner = await screen.findByTestId("claude-auth-error");
|
||||||
|
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
authErrorMessage,
|
authErrorMessage,
|
||||||
useClaudeTokenAcquisition,
|
useClaudeTokenAcquisition,
|
||||||
} from "../../hooks/useClaudeAuth";
|
} from "../../hooks/useClaudeAuth";
|
||||||
|
import {
|
||||||
|
ANTHROPIC_SIGN_IN_HOSTS,
|
||||||
|
sanitizeRelayUrl,
|
||||||
|
urlOrigin,
|
||||||
|
} from "../../lib/urlRelay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Project whose running container is borrowed to run the CLI. */
|
/** Project whose running container is borrowed to run the CLI. */
|
||||||
@@ -22,6 +27,9 @@ interface Props {
|
|||||||
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||||
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
||||||
finishing: { tone: "busy", label: "Finishing sign-in" },
|
finishing: { tone: "busy", label: "Finishing sign-in" },
|
||||||
|
// The CLI refused a code and is back at its prompt. Distinct from "failed":
|
||||||
|
// the flow is still live and another code will be accepted.
|
||||||
|
rejected: { tone: "error", label: "Code rejected — try again" },
|
||||||
succeeded: { tone: "ok", label: "Token stored" },
|
succeeded: { tone: "ok", label: "Token stored" },
|
||||||
failed: { tone: "error", label: "Authentication failed" },
|
failed: { tone: "error", label: "Authentication failed" },
|
||||||
};
|
};
|
||||||
@@ -83,13 +91,34 @@ export default function ClaudeAuthModal({
|
|||||||
? PHASE_STATUS.failed
|
? PHASE_STATUS.failed
|
||||||
: flow.codeSubmitted
|
: flow.codeSubmitted
|
||||||
? PHASE_STATUS.finishing
|
? PHASE_STATUS.finishing
|
||||||
: PHASE_STATUS.waiting;
|
: flow.codeRejections > 0
|
||||||
|
? PHASE_STATUS.rejected
|
||||||
|
: PHASE_STATUS.waiting;
|
||||||
|
|
||||||
|
// Split for display only. `flow.signInUrl` has already passed the host
|
||||||
|
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||||
|
const signInOrigin = flow.signInUrl ? (urlOrigin(flow.signInUrl) ?? "") : "";
|
||||||
|
const signInPath = flow.signInUrl
|
||||||
|
? flow.signInUrl.slice(signInOrigin.length)
|
||||||
|
: "";
|
||||||
|
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
if (!flow.signInUrl) return;
|
if (!flow.signInUrl) return;
|
||||||
setLinkError(null);
|
setLinkError(null);
|
||||||
|
// Re-validated at the sink. `extractSignInUrl` already applies the host
|
||||||
|
// allowlist, so a failure here means that invariant broke — which is the
|
||||||
|
// one moment it matters that the last step before the OS opener checks.
|
||||||
|
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||||
|
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
setLinkError(
|
||||||
|
"That link is not an Anthropic sign-in address and was not opened. Start authentication again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await openUrl(flow.signInUrl);
|
await openUrl(target);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLinkError(
|
setLinkError(
|
||||||
authErrorMessage(
|
authErrorMessage(
|
||||||
@@ -103,8 +132,19 @@ export default function ClaudeAuthModal({
|
|||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (!flow.signInUrl) return;
|
if (!flow.signInUrl) return;
|
||||||
setLinkError(null);
|
setLinkError(null);
|
||||||
|
// Copying is the manual route to the same browser, so it gets the same
|
||||||
|
// check — a link too dangerous to open is too dangerous to hand over.
|
||||||
|
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||||
|
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
setLinkError(
|
||||||
|
"That link is not an Anthropic sign-in address and was not copied. Start authentication again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(flow.signInUrl);
|
await navigator.clipboard.writeText(target);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLinkError(
|
setLinkError(
|
||||||
@@ -185,16 +225,32 @@ export default function ClaudeAuthModal({
|
|||||||
{flow.signInUrl ? (
|
{flow.signInUrl ? (
|
||||||
<div className="mt-1 space-y-1.5">
|
<div className="mt-1 space-y-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* The origin is rendered at full length and the path is the
|
||||||
|
only part allowed to truncate. A single `truncate` element
|
||||||
|
showing the whole URL is a spoofing primitive: pad the
|
||||||
|
front and the ellipsis eats the half that decides where the
|
||||||
|
user's Anthropic password goes. */}
|
||||||
<a
|
<a
|
||||||
href={flow.signInUrl}
|
href={flow.signInUrl}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleOpen();
|
void handleOpen();
|
||||||
}}
|
}}
|
||||||
className="min-w-0 flex-1 truncate px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
className="flex min-w-0 flex-1 items-baseline px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
||||||
title={flow.signInUrl}
|
title={flow.signInUrl}
|
||||||
>
|
>
|
||||||
{flow.signInUrl}
|
<span
|
||||||
|
data-testid="claude-auth-url-origin"
|
||||||
|
className="shrink-0 font-semibold [overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{signInOrigin}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="claude-auth-url-path"
|
||||||
|
className="min-w-0 truncate text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{signInPath}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<Button size="md" onClick={() => void handleOpen()}>
|
<Button size="md" onClick={() => void handleOpen()}>
|
||||||
Open
|
Open
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import GatewaySettings from "./GatewaySettings";
|
||||||
|
import type { AppSettings, GatewayStatus } from "../../lib/types";
|
||||||
|
|
||||||
|
const getGatewayStatus = vi.fn();
|
||||||
|
const stopGateway = vi.fn();
|
||||||
|
const startGateway = vi.fn();
|
||||||
|
const checkGatewayHealth = vi.fn();
|
||||||
|
const saveSettings = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
getGatewayStatus: () => getGatewayStatus(),
|
||||||
|
startGateway: () => startGateway(),
|
||||||
|
stopGateway: () => stopGateway(),
|
||||||
|
checkGatewayHealth: () => checkGatewayHealth(),
|
||||||
|
pullGatewayImage: vi.fn(),
|
||||||
|
buildGatewayImage: vi.fn(),
|
||||||
|
setGatewayApiKey: vi.fn(),
|
||||||
|
clearGatewayApiKey: vi.fn(),
|
||||||
|
getGatewayAuthToken: vi.fn(),
|
||||||
|
regenerateGatewayAuthToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||||
|
|
||||||
|
let appSettings: AppSettings | null = null;
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useSettings: () => ({ appSettings, saveSettings }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const settingsWithGateway = (enabled: boolean): AppSettings =>
|
||||||
|
({
|
||||||
|
gateway: { enabled, port: 4000, provider: "openai", api_base: null, models: [] },
|
||||||
|
}) as unknown as AppSettings;
|
||||||
|
|
||||||
|
const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
|
||||||
|
container_exists: true,
|
||||||
|
running: true,
|
||||||
|
port: 4000,
|
||||||
|
image_exists: true,
|
||||||
|
model_count: 0,
|
||||||
|
has_api_key: false,
|
||||||
|
base_url: "http://host.docker.internal:4000",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GatewaySettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
appSettings = settingsWithGateway(false);
|
||||||
|
getGatewayStatus.mockResolvedValue(status());
|
||||||
|
checkGatewayHealth.mockResolvedValue(true);
|
||||||
|
saveSettings.mockImplementation(async (s: AppSettings) => s);
|
||||||
|
stopGateway.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a working Stop button when the gateway is disabled but its container exists", async () => {
|
||||||
|
render(<GatewaySettings />);
|
||||||
|
|
||||||
|
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||||
|
// The configuration UI stays hidden — only the container row survives.
|
||||||
|
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||||
|
/gateway container is still present/i,
|
||||||
|
);
|
||||||
|
// Status is a word, not just a colour.
|
||||||
|
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||||
|
/Running on port 4000/,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(stop);
|
||||||
|
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
|
||||||
|
// Stopping re-reads status: once on mount, once after the action.
|
||||||
|
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing extra when the gateway is disabled and no container exists", async () => {
|
||||||
|
getGatewayStatus.mockResolvedValue(status({ container_exists: false, running: false }));
|
||||||
|
render(<GatewaySettings />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("gateway-leftover-container")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads container status after toggling the gateway off", async () => {
|
||||||
|
appSettings = settingsWithGateway(true);
|
||||||
|
render(<GatewaySettings />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// The backend stops the container as part of update_settings, so the UI has
|
||||||
|
// to re-read rather than trust the status it already has.
|
||||||
|
getGatewayStatus.mockResolvedValue(status({ running: false }));
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Model gateway" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(saveSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ gateway: expect.objectContaining({ enabled: false }) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import {
|
||||||
|
getGatewayStatus,
|
||||||
|
startGateway,
|
||||||
|
stopGateway,
|
||||||
|
checkGatewayHealth,
|
||||||
|
pullGatewayImage,
|
||||||
|
buildGatewayImage,
|
||||||
|
setGatewayApiKey,
|
||||||
|
clearGatewayApiKey,
|
||||||
|
getGatewayAuthToken,
|
||||||
|
regenerateGatewayAuthToken,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
|
import type { GatewayModel, GatewaySettings as GatewaySettingsType, GatewayStatus } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Field, { SwitchRow, inputClass, monoInputClass } from "../ui/Field";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY: GatewaySettingsType = {
|
||||||
|
enabled: false,
|
||||||
|
port: 4000,
|
||||||
|
provider: "openai",
|
||||||
|
api_base: null,
|
||||||
|
models: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings for the model gateway — the LiteLLM container Triple-C runs so that
|
||||||
|
* Claude Code, which only speaks the Anthropic Messages API, can be driven by
|
||||||
|
* an OpenAI key.
|
||||||
|
*
|
||||||
|
* The provider API key is write-only from here: it goes to the OS keychain and
|
||||||
|
* there is no command that reads it back, so the UI can only ever report
|
||||||
|
* whether one is stored.
|
||||||
|
*/
|
||||||
|
export default function GatewaySettings() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const gateway = appSettings?.gateway ?? DEFAULT_GATEWAY;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<GatewayStatus | null>(null);
|
||||||
|
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pulling, setPulling] = useState(false);
|
||||||
|
const [building, setBuilding] = useState(false);
|
||||||
|
const [log, setLog] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [provider, setProvider] = useState(gateway.provider);
|
||||||
|
const [port, setPort] = useState(String(gateway.port));
|
||||||
|
const [apiBase, setApiBase] = useState(gateway.api_base ?? "");
|
||||||
|
const [apiKeyDraft, setApiKeyDraft] = useState("");
|
||||||
|
const [savingKey, setSavingKey] = useState(false);
|
||||||
|
|
||||||
|
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const [confirmRotate, setConfirmRotate] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProvider(gateway.provider);
|
||||||
|
setPort(String(gateway.port));
|
||||||
|
setApiBase(gateway.api_base ?? "");
|
||||||
|
}, [gateway.provider, gateway.port, gateway.api_base]);
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const next = await getGatewayStatus();
|
||||||
|
setStatus(next);
|
||||||
|
setHealthy(next.running ? await checkGatewayHealth() : null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Gateway status failed:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStatus();
|
||||||
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a gateway settings change, then re-read the container status.
|
||||||
|
*
|
||||||
|
* `update_settings` reconciles the container itself — it stops the gateway
|
||||||
|
* when `enabled` goes false and recreates it on a port change — so the status
|
||||||
|
* we are holding is stale the moment the save returns.
|
||||||
|
*/
|
||||||
|
const patch = async (changes: Partial<GatewaySettingsType>) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
|
||||||
|
await refreshStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePort = async () => {
|
||||||
|
const parsed = parseInt(port, 10);
|
||||||
|
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
setPort(String(gateway.port));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await patch({ port: parsed });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setModels = (models: GatewayModel[]) => patch({ models });
|
||||||
|
|
||||||
|
const updateModel = (index: number, changes: Partial<GatewayModel>) =>
|
||||||
|
setModels(gateway.models.map((m, i) => (i === index ? { ...m, ...changes } : m)));
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withProgress = async (
|
||||||
|
event: string,
|
||||||
|
setBusy: (busy: boolean) => void,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
setBusy(true);
|
||||||
|
setLog(null);
|
||||||
|
setError(null);
|
||||||
|
const unlisten = await listen<string>(event, (e) => setLog(e.payload));
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveKey = async () => {
|
||||||
|
if (!apiKeyDraft.trim()) return;
|
||||||
|
setSavingKey(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setGatewayApiKey(apiKeyDraft);
|
||||||
|
setApiKeyDraft("");
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSavingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealToken = async () => {
|
||||||
|
try {
|
||||||
|
setAuthToken(await getGatewayAuthToken());
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateToken = async () => {
|
||||||
|
setConfirmRotate(false);
|
||||||
|
try {
|
||||||
|
setAuthToken(await regenerateGatewayAuthToken());
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (label: string, value: string) => {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(label);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tone: StatusTone = !status?.image_exists
|
||||||
|
? "off"
|
||||||
|
: status.running
|
||||||
|
? healthy === false
|
||||||
|
? "busy"
|
||||||
|
: "running"
|
||||||
|
: status.container_exists
|
||||||
|
? "stopped"
|
||||||
|
: "off";
|
||||||
|
|
||||||
|
const statusLabel = !status?.image_exists
|
||||||
|
? "No image"
|
||||||
|
: status.running
|
||||||
|
? healthy === false
|
||||||
|
? "Starting…"
|
||||||
|
: `Running on port ${status.port}`
|
||||||
|
: status.container_exists
|
||||||
|
? "Stopped"
|
||||||
|
: "Image ready";
|
||||||
|
|
||||||
|
// Rendered in whichever branch is live — only one of them ever mounts.
|
||||||
|
const errorLine = error ? (
|
||||||
|
<p className="text-xs text-[var(--error)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Model Gateway</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
|
Runs a pinned LiteLLM proxy in a container. Claude Code only speaks the Anthropic
|
||||||
|
Messages API, so an OpenAI key cannot drive it directly — the gateway serves{" "}
|
||||||
|
<code className="font-mono">/v1/messages</code> and translates each call to your
|
||||||
|
provider. Point a project's <strong>OpenAI Compatible</strong> backend at it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SwitchRow
|
||||||
|
label="Model gateway"
|
||||||
|
hint="Start the gateway container with Triple-C."
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Model gateway"
|
||||||
|
checked={gateway.enabled}
|
||||||
|
onChange={(value) => patch({ enabled: value })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Turning the gateway off hides its configuration, but a container that
|
||||||
|
already exists must stay reachable — otherwise a leftover container
|
||||||
|
keeps its port bound with no UI left to stop it.
|
||||||
|
*/}
|
||||||
|
{!gateway.enabled && status?.container_exists && (
|
||||||
|
<div className="space-y-2" data-testid="gateway-leftover-container">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||||
|
<Button variant="danger" disabled={loading} onClick={() => run(stopGateway)}>
|
||||||
|
{loading ? "Working…" : "Stop"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
The gateway container is still present. Stop it here if it is still running; it
|
||||||
|
will not be started again while the gateway is off.
|
||||||
|
</p>
|
||||||
|
{errorLine}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gateway.enabled && (
|
||||||
|
<>
|
||||||
|
{/* ── Container ─────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||||
|
{status?.image_exists && (
|
||||||
|
<Button
|
||||||
|
variant={status.running ? "danger" : "primary"}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => run(status.running ? stopGateway : startGateway)}
|
||||||
|
>
|
||||||
|
{loading ? "Working…" : status.running ? "Stop" : "Start"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={pulling || building}
|
||||||
|
onClick={() => withProgress("gateway-pull-progress", setPulling, pullGatewayImage)}
|
||||||
|
>
|
||||||
|
{pulling ? "Pulling…" : "Pull Image"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pulling || building}
|
||||||
|
onClick={() => withProgress("gateway-build-progress", setBuilding, buildGatewayImage)}
|
||||||
|
>
|
||||||
|
{building ? "Building…" : "Build Locally"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log && (
|
||||||
|
<pre className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
|
||||||
|
{log}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorLine}
|
||||||
|
|
||||||
|
{/* ── Provider ──────────────────────────────────────────────── */}
|
||||||
|
<Field
|
||||||
|
label="Provider"
|
||||||
|
hint="LiteLLM provider prefix. OpenAI is the common case; anything LiteLLM supports works (azure, gemini, groq, …)."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => setProvider(e.target.value)}
|
||||||
|
onBlur={() => patch({ provider: provider.trim() || "openai" })}
|
||||||
|
placeholder="openai"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Provider API key"
|
||||||
|
hint={
|
||||||
|
status?.has_api_key
|
||||||
|
? "A key is stored in your OS keychain. Enter a new one to replace it — it is never shown again."
|
||||||
|
: "Stored in your OS keychain, written only into the gateway container's config. Never shown again once saved."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={apiKeyDraft}
|
||||||
|
onChange={(e) => setApiKeyDraft(e.target.value)}
|
||||||
|
placeholder={status?.has_api_key ? "•••••••• (stored)" : "sk-…"}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={savingKey || !apiKeyDraft.trim()}
|
||||||
|
onClick={handleSaveKey}
|
||||||
|
>
|
||||||
|
{savingKey ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
{status?.has_api_key && (
|
||||||
|
<Button variant="danger" onClick={() => run(clearGatewayApiKey)}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Provider base URL (optional)"
|
||||||
|
hint="Override the provider's endpoint — Azure deployments, self-hosted OpenAI-compatible servers, and so on. Leave blank for the provider default."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={apiBase}
|
||||||
|
onChange={(e) => setApiBase(e.target.value)}
|
||||||
|
onBlur={() => patch({ api_base: apiBase.trim() || null })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Host port"
|
||||||
|
hint="Port the gateway is published on. Changing it recreates the container."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
onBlur={savePort}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* ── Models ────────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-medium text-[var(--text-primary)]">Models</div>
|
||||||
|
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Each row becomes one model the gateway serves. <strong>Name</strong> is what a
|
||||||
|
project puts in its model field; <strong>Model id</strong> is the provider's own
|
||||||
|
id. The gateway sends them as{" "}
|
||||||
|
<code className="font-mono">{provider || "openai"}/<model id></code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gateway.models.map((model, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Model ${index + 1} name`}
|
||||||
|
value={model.name}
|
||||||
|
onChange={(e) => updateModel(index, { name: e.target.value })}
|
||||||
|
placeholder="gpt-5.1"
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Model ${index + 1} provider id`}
|
||||||
|
value={model.model_id}
|
||||||
|
onChange={(e) => updateModel(index, { model_id: e.target.value })}
|
||||||
|
placeholder="gpt-5.1"
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove model ${index + 1}`}
|
||||||
|
onClick={() => setModels(gateway.models.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={() => setModels([...gateway.models, { name: "", model_id: "" }])}
|
||||||
|
>
|
||||||
|
Add model
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── What a project should use ─────────────────────────────── */}
|
||||||
|
<div className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3 py-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
Project settings for this gateway
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
|
||||||
|
values. The base URL below is the one your Docker engine actually needs —{" "}
|
||||||
|
<code className="font-mono">host.docker.internal</code> on Docker Desktop, the
|
||||||
|
bridge gateway address on native Linux, where that name is not injected into
|
||||||
|
containers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Base URL">
|
||||||
|
{(id) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
readOnly
|
||||||
|
value={status?.base_url ?? `http://host.docker.internal:${gateway.port}`}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
copy(
|
||||||
|
"url",
|
||||||
|
status?.base_url ?? `http://host.docker.internal:${gateway.port}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied === "url" ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Auth token"
|
||||||
|
hint="The gateway requires this on every request, which is what stops the published port being an open proxy onto your provider account."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
readOnly
|
||||||
|
type={authToken ? "text" : "password"}
|
||||||
|
value={authToken ?? "••••••••••••"}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
{authToken ? (
|
||||||
|
<Button onClick={() => copy("token", authToken)}>
|
||||||
|
{copied === "token" ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={revealToken}>Reveal</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="danger" onClick={() => setConfirmRotate(true)}>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmRotate && (
|
||||||
|
<Modal
|
||||||
|
title="Regenerate gateway auth token?"
|
||||||
|
onClose={() => setConfirmRotate(false)}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="md" onClick={() => setConfirmRotate(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="danger" onClick={rotateToken}>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Every project still using the current token will stop reaching the gateway until you
|
||||||
|
paste the new one into its model config. The gateway is recreated on its next start.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
|
type Field = "base_url" | "default_model_id" | "default_haiku_model_id";
|
||||||
|
|
||||||
|
export default function LlamaCppSettings() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
|
||||||
|
const globalLlamaCpp = appSettings?.global_llamacpp ?? {
|
||||||
|
base_url: null,
|
||||||
|
default_model_id: null,
|
||||||
|
default_haiku_model_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = async (field: Field, value: string) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
global_llamacpp: { ...globalLlamaCpp, [field]: value || null },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">llama.cpp Configuration</label>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Global defaults for a local or remote <code>llama-server</code>, which serves
|
||||||
|
the Anthropic Messages API directly. Used when a per-project field is blank.
|
||||||
|
Changes here require a container rebuild to take effect.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your llama-server. Used when a per-project llama.cpp base URL is blank. llama-server listens on port 8080 by default." /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalLlamaCpp.base_url ?? ""}
|
||||||
|
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||||
|
placeholder="http://host.docker.internal:8080"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project llama.cpp model is blank." /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalLlamaCpp.default_model_id ?? ""}
|
||||||
|
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||||
|
placeholder="qwen3.5-coder-30b"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you serve a second, smaller model." /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalLlamaCpp.default_haiku_model_id ?? ""}
|
||||||
|
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ export default function OllamaSettings() {
|
|||||||
const globalOllama = appSettings?.global_ollama ?? {
|
const globalOllama = appSettings?.global_ollama ?? {
|
||||||
base_url: null,
|
base_url: null,
|
||||||
default_model_id: null,
|
default_model_id: null,
|
||||||
|
default_haiku_model_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
const handleChange = async (
|
||||||
|
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({
|
await saveSettings({
|
||||||
...appSettings,
|
...appSettings,
|
||||||
@@ -47,6 +51,17 @@ export default function OllamaSettings() {
|
|||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you have pulled a second, smaller model." /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalOllama.default_haiku_model_id ?? ""}
|
||||||
|
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ export default function OpenAiCompatibleSettings() {
|
|||||||
const globalOai = appSettings?.global_openai_compatible ?? {
|
const globalOai = appSettings?.global_openai_compatible ?? {
|
||||||
base_url: null,
|
base_url: null,
|
||||||
default_model_id: null,
|
default_model_id: null,
|
||||||
|
default_haiku_model_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
const handleChange = async (
|
||||||
|
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({
|
await saveSettings({
|
||||||
...appSettings,
|
...appSettings,
|
||||||
@@ -22,8 +26,9 @@ export default function OpenAiCompatibleSettings() {
|
|||||||
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
|
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
|
Global defaults for a gateway that implements the Anthropic Messages API
|
||||||
Used when a per-project field is blank. Changes require a container rebuild.
|
(<code>POST /v1/messages</code>) — LiteLLM, for example. Used when a per-project
|
||||||
|
field is blank. Changes require a container rebuild.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -47,6 +52,17 @@ export default function OpenAiCompatibleSettings() {
|
|||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if your gateway also serves a smaller model." /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalOai.default_haiku_model_id ?? ""}
|
||||||
|
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||||
|
placeholder="(same as the model above)"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
|
|||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
import OllamaSettings from "./OllamaSettings";
|
import OllamaSettings from "./OllamaSettings";
|
||||||
|
import LlamaCppSettings from "./LlamaCppSettings";
|
||||||
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
|
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
|
||||||
|
import GatewaySettings from "./GatewaySettings";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
@@ -16,6 +18,7 @@ import Toggle from "../ui/Toggle";
|
|||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
|
import CertificateSettings from "./CertificateSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -159,13 +162,21 @@ export default function SettingsPanel() {
|
|||||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||||
<OllamaSettings />
|
<OllamaSettings />
|
||||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||||
|
<LlamaCppSettings />
|
||||||
|
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||||
<OpenAiCompatibleSettings />
|
<OpenAiCompatibleSettings />
|
||||||
|
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||||
|
<GatewaySettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
|
||||||
|
<CertificateSettings />
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
|
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
|
||||||
{/* Default SSH Key Directory */}
|
{/* Default SSH Key Directory */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
import type { Project } from "../../lib/types";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { ClearTokenOutcome, Project } from "../../lib/types";
|
||||||
|
|
||||||
const hasClaudeToken = vi.fn();
|
const hasClaudeToken = vi.fn();
|
||||||
const clearClaudeToken = vi.fn();
|
const clearClaudeToken = vi.fn();
|
||||||
@@ -63,8 +64,29 @@ describe("SharedAuthSettings", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
projects = [];
|
projects = [];
|
||||||
hasClaudeToken.mockResolvedValue(false);
|
hasClaudeToken.mockResolvedValue(false);
|
||||||
|
useAppState.setState({ toasts: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Open the confirmation and go through with it. */
|
||||||
|
async function revoke(outcome: Partial<ClearTokenOutcome>) {
|
||||||
|
projects = [running()];
|
||||||
|
hasClaudeToken.mockResolvedValue(true);
|
||||||
|
clearClaudeToken.mockResolvedValue({
|
||||||
|
snapshots_scrubbed: [],
|
||||||
|
snapshots_failed: [],
|
||||||
|
snapshots_superseded: [],
|
||||||
|
docker_unavailable: null,
|
||||||
|
...outcome,
|
||||||
|
});
|
||||||
|
render(<SharedAuthSettings />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
return useAppState.getState().toasts[0];
|
||||||
|
}
|
||||||
|
|
||||||
it("disables Authenticate and says why when nothing is running", async () => {
|
it("disables Authenticate and says why when nothing is running", async () => {
|
||||||
projects = [baseProject];
|
projects = [baseProject];
|
||||||
render(<SharedAuthSettings />);
|
render(<SharedAuthSettings />);
|
||||||
@@ -123,4 +145,48 @@ describe("SharedAuthSettings", () => {
|
|||||||
await screen.findByText("keyring backend unavailable");
|
await screen.findByText("keyring backend unavailable");
|
||||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Revoking has to tell the truth ──────────────────────────────────────
|
||||||
|
// Deleting the keychain entry is only part of it. `docker commit` copies the
|
||||||
|
// token into each project's snapshot image, and an image outlives every
|
||||||
|
// container built from it — so a "removed" message while a snapshot still
|
||||||
|
// holds a live ~1-year credential is the wrong thing to say.
|
||||||
|
|
||||||
|
it("says so plainly when snapshot images were cleared too", async () => {
|
||||||
|
const toast = await revoke({
|
||||||
|
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||||
|
});
|
||||||
|
expect(toast.kind).toBe("success");
|
||||||
|
expect(toast.message).toMatch(/1 snapshot image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an error, not success, when an image still holds the token", async () => {
|
||||||
|
const toast = await revoke({
|
||||||
|
snapshots_failed: ["triple-c-snapshot-p1:latest: image has child images"],
|
||||||
|
});
|
||||||
|
expect(toast.kind).toBe("error");
|
||||||
|
expect(toast.message).toMatch(/still in some images/i);
|
||||||
|
expect(toast.detail).toMatch(/triple-c-snapshot-p1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim the images are clean when Docker could not be reached", async () => {
|
||||||
|
const toast = await revoke({ docker_unavailable: "Docker is not running" });
|
||||||
|
expect(toast.kind).toBe("error");
|
||||||
|
expect(toast.detail).toMatch(/Docker could not be reached/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions a retained image layer without calling the revoke a failure", async () => {
|
||||||
|
const toast = await revoke({
|
||||||
|
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||||
|
snapshots_superseded: ["triple-c-snapshot-p1:latest"],
|
||||||
|
});
|
||||||
|
expect(toast.kind).toBe("success");
|
||||||
|
expect(toast.detail).toMatch(/still on disk because a container is running/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds plainly when there was nothing to scrub", async () => {
|
||||||
|
const toast = await revoke({});
|
||||||
|
expect(toast.kind).toBe("success");
|
||||||
|
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,13 +64,52 @@ export default function SharedAuthSettings() {
|
|||||||
const handleRevoke = async () => {
|
const handleRevoke = async () => {
|
||||||
setRevoking(true);
|
setRevoking(true);
|
||||||
try {
|
try {
|
||||||
await clearClaudeToken();
|
const outcome = await clearClaudeToken();
|
||||||
setConfirmRevoke(false);
|
setConfirmRevoke(false);
|
||||||
await refresh();
|
await refresh();
|
||||||
pushToast({
|
|
||||||
kind: "success",
|
// The keychain entry is gone either way. What matters here is the copy of
|
||||||
message: "Shared Claude token removed from the keychain.",
|
// the token that `docker commit` baked into each project's snapshot
|
||||||
});
|
// image: that one outlives every container, and `docker image inspect`
|
||||||
|
// will keep printing it until the image is rewritten. If that could not
|
||||||
|
// be done, the revocation is incomplete and saying "removed" would be a
|
||||||
|
// lie.
|
||||||
|
if (outcome.docker_unavailable) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Token removed from the keychain, but snapshots were not checked.",
|
||||||
|
detail:
|
||||||
|
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
|
||||||
|
"built before this version may still contain the token in its environment. " +
|
||||||
|
"Start Docker and revoke again to clear them.",
|
||||||
|
});
|
||||||
|
} else if (outcome.snapshots_failed.length > 0) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Token removed from the keychain, but it is still in some images.",
|
||||||
|
detail:
|
||||||
|
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
|
||||||
|
"still contain the token, readable via `docker image inspect`. Reset those " +
|
||||||
|
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
|
||||||
|
});
|
||||||
|
} else if (outcome.snapshots_scrubbed.length > 0) {
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
|
||||||
|
detail:
|
||||||
|
outcome.snapshots_superseded.length > 0
|
||||||
|
? "The pre-rewrite image layer for " +
|
||||||
|
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
|
||||||
|
"container is running from it. It goes away once that project is restarted " +
|
||||||
|
"(which recreates the container) and Docker prunes the leftover."
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: "Shared Claude token removed from the keychain.",
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
@@ -220,6 +259,13 @@ export default function SharedAuthSettings() {
|
|||||||
container starts. Existing running containers keep working until they are
|
container starts. Existing running containers keep working until they are
|
||||||
restarted.
|
restarted.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
||||||
|
Each project’s snapshot image is also rewritten, because{" "}
|
||||||
|
<code className="font-mono">docker commit</code> copies the token into it
|
||||||
|
and an image outlives every container built from it. If any image
|
||||||
|
cannot be rewritten you will be told which, and the token stays readable
|
||||||
|
in it until that project is Reset.
|
||||||
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,19 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
import {
|
||||||
|
awsSsoRefresh,
|
||||||
|
openPageInContainerBrowser,
|
||||||
|
uploadHostFileToTerminal,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { UrlDetector } from "../../lib/urlDetector";
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
|
import {
|
||||||
|
RelayRateLimiter,
|
||||||
|
URL_RELAY_OSC,
|
||||||
|
parseUrlRelayOsc,
|
||||||
|
sanitizeRelayUrl,
|
||||||
|
} from "../../lib/urlRelay";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast from "./UrlToast";
|
||||||
import { trimSelection } from "./trimSelection";
|
import { trimSelection } from "./trimSelection";
|
||||||
import TerminalContextMenu from "./TerminalContextMenu";
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
@@ -37,7 +47,41 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||||||
);
|
);
|
||||||
|
|
||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
// One toast slot, two producers: the heuristic long-URL detector and the
|
||||||
|
// container's explicit "open this in the host browser" relay (OSC 7777).
|
||||||
|
// Sharing the slot keeps them from stacking on top of each other.
|
||||||
|
//
|
||||||
|
// Both producers read the container's PTY output, so both are untrusted, and
|
||||||
|
// both must go through `sanitizeRelayUrl` before anything is stored here —
|
||||||
|
// see `promptUrl` below, which is the only writer.
|
||||||
|
//
|
||||||
|
// `seq` exists because the slot is shared and long-lived: a second prompt
|
||||||
|
// replacing a first would otherwise mutate the toast in place, swapping the
|
||||||
|
// text under a user who is mid-read and mid-click. Keying the toast on it
|
||||||
|
// remounts the component, so a new URL is unmistakably a new prompt.
|
||||||
|
const [urlPrompt, setUrlPrompt] = useState<{
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
seq: number;
|
||||||
|
} | null>(null);
|
||||||
|
const promptSeqRef = useRef(0);
|
||||||
|
const relayLimiterRef = useRef(new RelayRateLimiter());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only writer of the prompt slot. Re-validates whatever the caller
|
||||||
|
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||||||
|
* but the heuristic detector branch has been through nothing at all, and a
|
||||||
|
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
|
||||||
|
*/
|
||||||
|
const promptUrl = useCallback((raw: string, label: string) => {
|
||||||
|
const url = sanitizeRelayUrl(raw);
|
||||||
|
if (!url) {
|
||||||
|
console.warn("Refusing to prompt for a URL that failed validation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptSeqRef.current += 1;
|
||||||
|
setUrlPrompt({ url, label, seq: promptSeqRef.current });
|
||||||
|
}, []);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||||
@@ -151,9 +195,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||||
// that matches URLs even if they lack trailing path segments (the default regex
|
// that matches URLs even if they lack trailing path segments (the default regex
|
||||||
// misses OAuth URLs that end mid-line).
|
// misses OAuth URLs that end mid-line).
|
||||||
const urlRegex = /https?:\/\/[^\s'"\x07]+/;
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||||
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
// Same sink, same rule: what xterm matched came off the container's
|
||||||
|
// output, so it is validated before it reaches the OS opener. A click
|
||||||
|
// here is a deliberate act on visible text, but "visible" is exactly
|
||||||
|
// what a userinfo-spoofed URL subverts.
|
||||||
|
const safe = sanitizeRelayUrl(uri);
|
||||||
|
if (!safe) {
|
||||||
|
console.warn("Refusing to open a link that failed validation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||||
}, { urlRegex });
|
}, { urlRegex });
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
@@ -212,6 +266,31 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
|
||||||
|
// opened in a browser. The container has none; `triple-c-open` (installed
|
||||||
|
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
|
||||||
|
// here instead.
|
||||||
|
//
|
||||||
|
// The container is untrusted, so this never opens anything by itself:
|
||||||
|
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
|
||||||
|
// rate-limited, then the user gets the same confirmation toast the
|
||||||
|
// long-URL detector uses. One click is a small price for not handing a
|
||||||
|
// sandboxed agent a "make the host's logged-in browser fetch this"
|
||||||
|
// primitive.
|
||||||
|
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
|
||||||
|
const url = parseUrlRelayOsc(data);
|
||||||
|
if (!url) {
|
||||||
|
console.warn("URL relay: rejected request from container");
|
||||||
|
return true; // consumed either way — never let it reach the screen
|
||||||
|
}
|
||||||
|
if (!relayLimiterRef.current.allow(url)) {
|
||||||
|
console.warn("URL relay: rate-limited", url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
promptUrl(url, "Container asked to open a URL");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Handle user input -> backend
|
// Handle user input -> backend
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
@@ -295,7 +374,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
// The width is read per scan, not captured: only a break the terminal
|
||||||
|
// itself inserted may be deleted, and where that is moves with every
|
||||||
|
// resize.
|
||||||
|
const detector = new UrlDetector(
|
||||||
|
(url) => promptUrl(url, "Long URL detected"),
|
||||||
|
() => termRef.current?.cols ?? 0,
|
||||||
|
);
|
||||||
detectorRef.current = detector;
|
detectorRef.current = detector;
|
||||||
|
|
||||||
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
||||||
@@ -369,6 +454,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
ssoTriggeredRef.current = false;
|
ssoTriggeredRef.current = false;
|
||||||
ssoBufferRef.current = "";
|
ssoBufferRef.current = "";
|
||||||
osc52Disposable.dispose();
|
osc52Disposable.dispose();
|
||||||
|
relayDisposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
scrollDisposable.dispose();
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
@@ -425,10 +511,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
// Auto-dismiss toast after 30 seconds
|
// Auto-dismiss toast after 30 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detectedUrl) return;
|
if (!urlPrompt) return;
|
||||||
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [detectedUrl]);
|
}, [urlPrompt]);
|
||||||
|
|
||||||
// Auto-dismiss image paste message after 3 seconds
|
// Auto-dismiss image paste message after 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -438,13 +524,63 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}, [imagePasteMsg]);
|
}, [imagePasteMsg]);
|
||||||
|
|
||||||
const handleOpenUrl = useCallback(() => {
|
const handleOpenUrl = useCallback(() => {
|
||||||
if (detectedUrl) {
|
if (!urlPrompt) return;
|
||||||
openUrl(detectedUrl).catch((e) =>
|
// Validated again at the sink. `promptUrl` is the only writer and already
|
||||||
console.error("Failed to open URL:", e),
|
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||||
);
|
// precisely when it matters that the last thing before `openUrl` checks.
|
||||||
setDetectedUrl(null);
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
|
setUrlPrompt(null);
|
||||||
|
if (!safe) {
|
||||||
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [detectedUrl]);
|
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||||
|
}, [urlPrompt]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the prompted URL in the container's own browser instead of the host's.
|
||||||
|
*
|
||||||
|
* For a sign-in this is the shorter path: the callback listener the tool is
|
||||||
|
* waiting on is inside the container, so a container-side browser closes the
|
||||||
|
* loop with nothing crossing to the host. The page is published to the
|
||||||
|
* project's Browser tab, which is where the user completes it by hand.
|
||||||
|
*/
|
||||||
|
const handleOpenUrlInContainer = useCallback(() => {
|
||||||
|
if (!urlPrompt) return;
|
||||||
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
|
setUrlPrompt(null);
|
||||||
|
if (!safe) {
|
||||||
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!projectId) return;
|
||||||
|
// Land on the pane that will show it, before the work starts: opening takes
|
||||||
|
// several seconds, and the progress line lives there.
|
||||||
|
useAppState.getState().openProjectHomeTab(projectId, "browser");
|
||||||
|
// A sign-in page is the one case where the *window* size matters least and
|
||||||
|
// the layout matters most, so it gets the ordinary desktop viewport.
|
||||||
|
// `true`: from a terminal there is no Browser pane on screen, so the page
|
||||||
|
// needs a window of its own or it opens somewhere the user isn't looking.
|
||||||
|
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
|
||||||
|
.then((result) => {
|
||||||
|
const push = useAppState.getState().pushToast;
|
||||||
|
if (result.error) {
|
||||||
|
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||||
|
} else {
|
||||||
|
push({
|
||||||
|
kind: "success",
|
||||||
|
message: "Opened in the container’s browser",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not open it in the container’s browser",
|
||||||
|
detail: String(e),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [urlPrompt, projectId]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = useCallback(() => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
@@ -516,11 +652,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
ref={terminalContainerRef}
|
ref={terminalContainerRef}
|
||||||
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||||
>
|
>
|
||||||
{detectedUrl && (
|
{urlPrompt && (
|
||||||
<UrlToast
|
<UrlToast
|
||||||
url={detectedUrl}
|
// A different URL is a different prompt, not an edit of this one.
|
||||||
|
key={urlPrompt.seq}
|
||||||
|
url={urlPrompt.url}
|
||||||
|
label={urlPrompt.label}
|
||||||
onOpen={handleOpenUrl}
|
onOpen={handleOpenUrl}
|
||||||
onDismiss={() => setDetectedUrl(null)}
|
onOpenInContainer={handleOpenUrlInContainer}
|
||||||
|
onDismiss={() => setUrlPrompt(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{imagePasteMsg && (
|
{imagePasteMsg && (
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import UrlToast from "./UrlToast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The toast is the *only* thing standing between a container-chosen URL and
|
||||||
|
* the host's browser, so what it shows has to be what will be opened — and the
|
||||||
|
* part that decides that is the origin.
|
||||||
|
*/
|
||||||
|
describe("UrlToast", () => {
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
it("shows the origin separately from the truncatable remainder", () => {
|
||||||
|
render(
|
||||||
|
<UrlToast
|
||||||
|
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||||
|
onOpen={noop}
|
||||||
|
onDismiss={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
||||||
|
"https://github.com",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("url-toast-rest")).toHaveTextContent(
|
||||||
|
"/login/device?code=ABCD-EFGH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the origin intact when the path is long enough to push it out", () => {
|
||||||
|
const url = `https://evil.tld/${"padding/".repeat(200)}end`;
|
||||||
|
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
||||||
|
// The registrable domain must be present in its own element, whole. A
|
||||||
|
// single ellipsised line would render this and show only the padding.
|
||||||
|
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
||||||
|
"https://evil.tld",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the whole URL as a tooltip", () => {
|
||||||
|
const url = "https://example.com/a/b?c=d";
|
||||||
|
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
||||||
|
expect(screen.getByTestId("url-toast-url")).toHaveAttribute("title", url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces itself, so a replacement prompt is not silent", () => {
|
||||||
|
render(
|
||||||
|
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens only via the button, never on its own", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(
|
||||||
|
<UrlToast url="https://example.com/" onOpen={onOpen} onDismiss={noop} />,
|
||||||
|
);
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
screen.getByRole("button", { name: "Open" }).click();
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,48 @@
|
|||||||
|
import { urlOrigin } from "../../lib/urlRelay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Heading above the URL. Says why the toast appeared. */
|
||||||
|
label?: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||||
|
* the project has no browser to open it in. */
|
||||||
|
onOpenInContainer?: () => void;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
/**
|
||||||
|
* Confirmation prompt for a URL something inside the container wants opened in
|
||||||
|
* the host browser.
|
||||||
|
*
|
||||||
|
* The origin is rendered separately from the rest of the URL and is never
|
||||||
|
* truncated. A single `nowrap`/`ellipsis` line looks tidy but is a spoofing
|
||||||
|
* primitive: `https://accounts.example.com/....(600 chars)....@evil.tld/` shows
|
||||||
|
* the reassuring half and hides the half that decides where the request goes.
|
||||||
|
* `sanitizeRelayUrl` already rejects the userinfo form; showing the origin in
|
||||||
|
* full is the belt to that braces, and it also covers the plainer case of a
|
||||||
|
* long path pushing the host out of view.
|
||||||
|
*
|
||||||
|
* Render this with a `key` that changes whenever the URL does. The prompt slot
|
||||||
|
* is shared and long-lived, so without one React mutates the node in place: the
|
||||||
|
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||||
|
* B that arrived a second later.
|
||||||
|
*/
|
||||||
|
export default function UrlToast({
|
||||||
|
url,
|
||||||
|
label = "Long URL detected",
|
||||||
|
onOpen,
|
||||||
|
onOpenInContainer,
|
||||||
|
onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const origin = urlOrigin(url);
|
||||||
|
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="animate-slide-down"
|
className="animate-slide-down"
|
||||||
|
role="status"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 12,
|
top: 12,
|
||||||
@@ -33,19 +68,46 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Long URL detected
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
data-testid="url-toast-url"
|
||||||
|
title={url}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
overflow: "hidden",
|
display: "flex",
|
||||||
textOverflow: "ellipsis",
|
alignItems: "baseline",
|
||||||
whiteSpace: "nowrap",
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{url}
|
{origin && (
|
||||||
|
<span
|
||||||
|
data-testid="url-toast-origin"
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
// The part that decides where the credentials go. It wraps
|
||||||
|
// rather than truncates, whatever else has to give.
|
||||||
|
flexShrink: 0,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{origin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
data-testid="url-toast-rest"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rest}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,6 +135,30 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
|||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onOpenInContainer && (
|
||||||
|
// A sign-in completed in the *container's* browser lands its callback
|
||||||
|
// on the container's own loopback, which is where the tool waiting for
|
||||||
|
// it is listening — no host round trip, no auth bridge.
|
||||||
|
<button
|
||||||
|
onClick={onOpenInContainer}
|
||||||
|
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In container
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
import {
|
||||||
|
authErrorMessage,
|
||||||
|
extractSignInUrl,
|
||||||
|
pickSignInUrl,
|
||||||
|
} from "./useClaudeAuth";
|
||||||
|
|
||||||
describe("extractSignInUrl", () => {
|
describe("extractSignInUrl", () => {
|
||||||
it("finds the authorize URL in realistic setup-token output", () => {
|
it("finds the authorize URL in realistic setup-token output", () => {
|
||||||
@@ -35,6 +39,108 @@ describe("extractSignInUrl", () => {
|
|||||||
const text = `https://claude.ai/oauth/authorize?code=tr\n${full}\n`;
|
const text = `https://claude.ai/oauth/authorize?code=tr\n${full}\n`;
|
||||||
expect(extractSignInUrl(text)).toBe(full);
|
expect(extractSignInUrl(text)).toBe(full);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The spoof this function exists to refuse ──────────────────────────────
|
||||||
|
// The transcript is container output. Everything below is a URL a misbehaving
|
||||||
|
// sandboxed agent can print at will, and the modal renders whatever comes
|
||||||
|
// back under a heading that says "Sign in with Anthropic".
|
||||||
|
|
||||||
|
it("rejects userinfo that makes an attacker's host read as Anthropic's", () => {
|
||||||
|
// Displays as `https://claude.ai...` in anything that truncates; navigates
|
||||||
|
// to evil.tld and harvests the real credential.
|
||||||
|
const spoof =
|
||||||
|
"https://claude.ai@evil.tld/oauth/authorize?" + "padding=".repeat(40);
|
||||||
|
expect(extractSignInUrl(`Use this url to sign in:\n${spoof}\n`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a longer hostile URL displace the real one", () => {
|
||||||
|
const real = "https://claude.ai/oauth/authorize?code=true&client_id=abc";
|
||||||
|
const longer =
|
||||||
|
"https://evil.tld/oauth/authorize?" + "x".repeat(real.length * 2);
|
||||||
|
expect(extractSignInUrl(`${real}\n${longer}\n`)).toBe(real);
|
||||||
|
// ...and the same when the hostile one is printed first.
|
||||||
|
expect(extractSignInUrl(`${longer}\n${real}\n`)).toBe(real);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a host that merely contains an Anthropic domain", () => {
|
||||||
|
expect(
|
||||||
|
extractSignInUrl("Sign in: https://claude.ai.evil.tld/oauth/authorize\n"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
extractSignInUrl("Sign in: https://evil.tld/claude.ai/oauth/authorize\n"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-http schemes and control characters smuggled into the link", () => {
|
||||||
|
expect(extractSignInUrl("Open javascript:alert(1) to continue\n")).toBeNull();
|
||||||
|
expect(
|
||||||
|
extractSignInUrl("https://claude.ai/oauth\u0000/authorize\n"),
|
||||||
|
).toBe("https://claude.ai/oauth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the first legitimate link, not the longest", () => {
|
||||||
|
const first = "https://claude.ai/oauth/authorize?code=true";
|
||||||
|
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||||
|
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Why the scraper is only the fallback ─────────────────────────────────
|
||||||
|
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
||||||
|
// *visible* text of it to the terminal width, so the transcript holds five
|
||||||
|
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
||||||
|
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
||||||
|
// anything.
|
||||||
|
|
||||||
|
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
||||||
|
const slices = [
|
||||||
|
FULL_URL.slice(0, 80),
|
||||||
|
FULL_URL.slice(80, 160),
|
||||||
|
FULL_URL.slice(160, 240),
|
||||||
|
FULL_URL.slice(240, 320),
|
||||||
|
FULL_URL.slice(320),
|
||||||
|
];
|
||||||
|
const scraped = extractSignInUrl(slices.join("\n"));
|
||||||
|
|
||||||
|
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
||||||
|
// the "extends the current pick" rule cannot join them, and guessing at
|
||||||
|
// line joins on an untrusted stream is not on the table.
|
||||||
|
expect(scraped).toBe(slices[0]);
|
||||||
|
expect(scraped).not.toBe(FULL_URL);
|
||||||
|
|
||||||
|
// The hyperlink parameter carries the whole thing, and that is what the
|
||||||
|
// hook prefers.
|
||||||
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
||||||
|
* 2.1.226). */
|
||||||
|
const FULL_URL =
|
||||||
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||||
|
|
||||||
|
describe("pickSignInUrl", () => {
|
||||||
|
it("keeps a 346-character authorize URL intact", () => {
|
||||||
|
expect(FULL_URL).toHaveLength(346);
|
||||||
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the same host allowlist to a hyperlink target", () => {
|
||||||
|
// An OSC 8 parameter is container output like anything else, and it is
|
||||||
|
// never displayed — so it is the *easier* place to hide a hostile host.
|
||||||
|
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
||||||
|
expect(
|
||||||
|
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
||||||
|
).toBeNull();
|
||||||
|
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
||||||
|
expect(pickSignInUrl([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a later hyperlink displace the one already shown", () => {
|
||||||
|
const real = `${FULL_URL}`;
|
||||||
|
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
||||||
|
expect(pickSignInUrl([real, spoof])).toBe(real);
|
||||||
|
expect(pickSignInUrl([spoof, real])).toBe(real);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authErrorMessage", () => {
|
describe("authErrorMessage", () => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||||
import type {
|
import type {
|
||||||
|
ClaudeTokenCodeRejectedEvent,
|
||||||
|
ClaudeTokenLinkEvent,
|
||||||
ClaudeTokenOutputEvent,
|
ClaudeTokenOutputEvent,
|
||||||
ClaudeTokenProgressEvent,
|
ClaudeTokenProgressEvent,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
@@ -18,10 +21,17 @@ import type {
|
|||||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||||
const PROGRESS_EVENT = "claude-token-progress";
|
const PROGRESS_EVENT = "claude-token-progress";
|
||||||
const OUTPUT_EVENT = "claude-token-output";
|
const OUTPUT_EVENT = "claude-token-output";
|
||||||
|
const LINK_EVENT = "claude-token-link";
|
||||||
|
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
|
||||||
|
|
||||||
/** Bound on the retained transcript. The tail is the interesting part. */
|
/** Bound on the retained transcript. The tail is the interesting part. */
|
||||||
const MAX_OUTPUT = 64 * 1024;
|
const MAX_OUTPUT = 64 * 1024;
|
||||||
|
|
||||||
|
/** Bound on retained sign-in candidates. The backend already deduplicates
|
||||||
|
* consecutive repeats; this stops a container that prints a fresh hyperlink
|
||||||
|
* every frame from growing state without limit. */
|
||||||
|
const MAX_LINKS = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
||||||
* backend writes its errors as complete, actionable sentences ("The container
|
* backend writes its errors as complete, actionable sentences ("The container
|
||||||
@@ -37,31 +47,70 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
* Choose one sign-in URL from a list of candidates.
|
||||||
*
|
*
|
||||||
* Prefers an OAuth-looking URL, and among candidates prefers the longest: a
|
* **Every candidate is container output, so all of them are
|
||||||
* TUI repaints, and a repaint can land a truncated copy of the same URL in the
|
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||||
* transcript. Longest-wins means a partial frame never replaces the full link.
|
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||||
|
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||||
|
* user who follows it types their real Anthropic credentials into whatever it
|
||||||
|
* resolves to. Three rules follow, and none of them are optional:
|
||||||
|
*
|
||||||
|
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
||||||
|
* host allowlist. Only Anthropic's own domains can be a sign-in link;
|
||||||
|
* userinfo (`https://claude.ai@evil.tld/...`) and control characters are
|
||||||
|
* rejected there.
|
||||||
|
* - The **first** surviving candidate wins. The previous rule was
|
||||||
|
* longest-wins, which handed the choice to the attacker: pad a hostile URL
|
||||||
|
* and it displaces the real one that came before it.
|
||||||
|
* - The one exception is a candidate that *extends* the current pick, i.e.
|
||||||
|
* starts with it. That is the case longest-wins existed for — a repainting
|
||||||
|
* TUI can land a truncated copy of the same link in the transcript before
|
||||||
|
* the complete one — and it cannot swap the origin, because a longer string
|
||||||
|
* with the same prefix has the same host.
|
||||||
*/
|
*/
|
||||||
export function extractSignInUrl(text: string): string | null {
|
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||||
const matches = text.match(/https?:\/\/[^\s"'<>`]+/g);
|
const cleaned = candidates
|
||||||
if (!matches) return null;
|
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||||
|
.filter((url): url is string => url !== null);
|
||||||
const cleaned = matches
|
|
||||||
// Trailing punctuation belongs to the prose, not the URL.
|
|
||||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
|
||||||
.filter((url) => url.length > "https://".length);
|
|
||||||
|
|
||||||
const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
|
const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
|
||||||
const pool = oauth.length > 0 ? oauth : cleaned;
|
const pool = oauth.length > 0 ? oauth : cleaned;
|
||||||
|
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
for (const url of pool) {
|
for (const url of pool) {
|
||||||
if (best === null || url.length >= best.length) best = url;
|
if (best === null || url.startsWith(best)) best = url;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
|
||||||
|
*
|
||||||
|
* **This is the fallback, not the primary route.** The CLI emits the URL as an
|
||||||
|
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
|
||||||
|
* terminal width — measured at 80 columns, a 346-character URL arrives as five
|
||||||
|
* 80-character pieces on five lines. Nothing scraping the visible text can put
|
||||||
|
* those back together: the pieces share no prefix, so the "extends the current
|
||||||
|
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
|
||||||
|
* untrusted stream is exactly the sort of thing the rules above exist to
|
||||||
|
* forbid. What comes out is the first 80 characters — a URL that parses, that
|
||||||
|
* points at claude.com, and that cannot authorise anything.
|
||||||
|
*
|
||||||
|
* So the backend lifts the whole URL out of the hyperlink parameter and sends
|
||||||
|
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
|
||||||
|
* This remains for CLI versions that print a bare URL with no hyperlink at all,
|
||||||
|
* where a URL narrow enough not to wrap is recovered correctly.
|
||||||
|
*/
|
||||||
|
export function extractSignInUrl(text: string): string | null {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
// Trailing punctuation belongs to the prose, not the URL.
|
||||||
|
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Token presence
|
// Token presence
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -114,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
|||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
codeSubmitted: boolean;
|
codeSubmitted: boolean;
|
||||||
submitError: string | null;
|
submitError: string | null;
|
||||||
|
/**
|
||||||
|
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
|
||||||
|
* still alive and waiting for another one — a recoverable state, not the end
|
||||||
|
* of the flow.
|
||||||
|
*/
|
||||||
|
codeRejections: number;
|
||||||
submitCode: (code: string) => Promise<boolean>;
|
submitCode: (code: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [codeRejections, setCodeRejections] = useState(0);
|
||||||
|
// Candidates from `claude-token-link`, in arrival order. Kept as a list
|
||||||
|
// rather than a single value so `pickSignInUrl` applies the same first-wins
|
||||||
|
// rule here as it does to the scraped transcript — the CLI reprints the same
|
||||||
|
// hyperlink after every retry, and a *different* one arriving later must not
|
||||||
|
// be able to displace the one the user was already shown.
|
||||||
|
const [links, setLinks] = useState<string[]>([]);
|
||||||
|
|
||||||
// Held in a ref so a fresh callback identity cannot restart the flow.
|
// Held in a ref so a fresh callback identity cannot restart the flow.
|
||||||
const succeededRef = useRef(onSucceeded);
|
const succeededRef = useRef(onSucceeded);
|
||||||
@@ -174,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
|||||||
: next;
|
: next;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
|
||||||
|
if (payload.project_id !== projectId) return;
|
||||||
|
setLinks((prev) =>
|
||||||
|
prev.includes(payload.url) || prev.length >= MAX_LINKS
|
||||||
|
? prev
|
||||||
|
: [...prev, payload.url],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await register<ClaudeTokenCodeRejectedEvent>(
|
||||||
|
CODE_REJECTED_EVENT,
|
||||||
|
(payload) => {
|
||||||
|
if (payload.project_id !== projectId) return;
|
||||||
|
// The CLI is alive and back at its prompt, so this is a correction
|
||||||
|
// the user can act on — not a failure. Re-open the input and say
|
||||||
|
// why, rather than leaving "Finishing sign-in" on screen forever.
|
||||||
|
setCodeRejections((n) => n + 1);
|
||||||
|
setCodeSubmitted(false);
|
||||||
|
setSubmitError(payload.message);
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPhase("failed");
|
setPhase("failed");
|
||||||
@@ -242,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
// The hyperlink parameter wins whenever there is one: it is the only place
|
||||||
|
// the CLI emits the URL contiguously. Scraping the visible text is the
|
||||||
|
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
||||||
|
const signInUrl = useMemo(
|
||||||
|
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
||||||
|
[links, output],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase,
|
phase,
|
||||||
@@ -253,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
|||||||
submitting,
|
submitting,
|
||||||
codeSubmitted,
|
codeSubmitted,
|
||||||
submitError,
|
submitError,
|
||||||
|
codeRejections,
|
||||||
submitCode,
|
submitCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { useContainerMigration } from "./useContainerMigration";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationReport,
|
||||||
|
MigrationState,
|
||||||
|
Project,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
const getContainerStaleness = vi.fn();
|
||||||
|
const getMigrationState = vi.fn();
|
||||||
|
const migrateProjectToBase = vi.fn();
|
||||||
|
const confirmMigration = vi.fn();
|
||||||
|
const rollbackMigration = vi.fn();
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
let progress: string | undefined;
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
getContainerStaleness: (...a: unknown[]) => getContainerStaleness(...a),
|
||||||
|
getMigrationState: (...a: unknown[]) => getMigrationState(...a),
|
||||||
|
migrateProjectToBase: (...a: unknown[]) => migrateProjectToBase(...a),
|
||||||
|
confirmMigration: (...a: unknown[]) => confirmMigration(...a),
|
||||||
|
rollbackMigration: (...a: unknown[]) => rollbackMigration(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) =>
|
||||||
|
selector({ pushToast, containerProgress: { p1: progress } }),
|
||||||
|
{
|
||||||
|
getState: () => ({ setContainerProgress: () => {} }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
stale: true,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: ["/usr/bin/socat"],
|
||||||
|
missing_features: ["Auth bridge tunnel (socat)"],
|
||||||
|
apt_delta: ["socat"],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
unpreserved_data: [],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRESH: ContainerStaleness = {
|
||||||
|
...STALE,
|
||||||
|
stale: false,
|
||||||
|
base_image_id: "sha256:bbb",
|
||||||
|
missing_paths: [],
|
||||||
|
missing_features: [],
|
||||||
|
apt_delta: [],
|
||||||
|
outdated_package_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLEAN: MigrationReport = {
|
||||||
|
phase: "succeeded",
|
||||||
|
packages_requested: ["socat"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function state(overrides: Partial<MigrationState> = {}): MigrationState {
|
||||||
|
return {
|
||||||
|
phase: "in-progress",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||||
|
staging_path: null,
|
||||||
|
options: OPTIONS,
|
||||||
|
plan: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = { id: "p1", name: "api-server", container_id: "c1", status: "stopped" } as Project;
|
||||||
|
|
||||||
|
describe("useContainerMigration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
progress = undefined;
|
||||||
|
getContainerStaleness.mockResolvedValue(STALE);
|
||||||
|
getMigrationState.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes staleness for a container that exists", async () => {
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
expect(getContainerStaleness).toHaveBeenCalledWith("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not probe a project whose container was never created", async () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useContainerMigration({ ...project, container_id: null } as Project),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(getMigrationState).toHaveBeenCalled());
|
||||||
|
expect(getContainerStaleness).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an absent banner rather than an error one when the probe fails", async () => {
|
||||||
|
getContainerStaleness.mockRejectedValue(new Error("no such container"));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.probing).toBe(false));
|
||||||
|
expect(result.current.staleness).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the options through and keeps the report", async () => {
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
|
||||||
|
getContainerStaleness.mockResolvedValue(FRESH);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", {
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
expect(result.current.report).toEqual(CLEAN);
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a rejected migrate call into a failed report, not a silent nothing", async () => {
|
||||||
|
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.report?.phase).toBe("failed");
|
||||||
|
expect(result.current.report?.message).toMatch(/docker daemon went away/);
|
||||||
|
expect(result.current.report?.rollback_available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the report and re-probes once the migration is kept", async () => {
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
confirmMigration.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
getContainerStaleness.mockResolvedValue(FRESH);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.keep();
|
||||||
|
});
|
||||||
|
expect(confirmMigration).toHaveBeenCalledWith("p1");
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(FRESH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says out loud that a rollback left the volumes alone", async () => {
|
||||||
|
rollbackMigration.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.rollback();
|
||||||
|
});
|
||||||
|
expect(rollbackMigration).toHaveBeenCalledWith("p1");
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "success",
|
||||||
|
detail: expect.stringMatching(/Volumes were not touched/i),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the record when a report is dismissed, not just the local state", async () => {
|
||||||
|
// Dismiss is the *only* action offered when `rollback_available` is false.
|
||||||
|
// As local state it left an `awaiting-confirmation` record on disk that
|
||||||
|
// came back on the next mount and made every future migration refuse with
|
||||||
|
// "already has a finished migration waiting for a decision" — unrecoverable
|
||||||
|
// without deleting JSON by hand.
|
||||||
|
confirmMigration.mockResolvedValue(undefined);
|
||||||
|
migrateProjectToBase.mockResolvedValue({ ...CLEAN, rollback_available: false });
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start(OPTIONS);
|
||||||
|
});
|
||||||
|
expect(result.current.report).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dismiss();
|
||||||
|
});
|
||||||
|
expect(confirmMigration).toHaveBeenCalledWith("p1");
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the report on screen when dismissing it could not be recorded", async () => {
|
||||||
|
confirmMigration.mockRejectedValue(new Error("disk is read-only"));
|
||||||
|
migrateProjectToBase.mockResolvedValue({ ...CLEAN, rollback_available: false });
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start(OPTIONS);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dismiss();
|
||||||
|
});
|
||||||
|
expect(result.current.report).not.toBeNull();
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: "error" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the probe as settled only once it has actually landed", async () => {
|
||||||
|
// Everything downstream reads an unlanded probe's empty arrays as "nothing
|
||||||
|
// found", so "settled" has to be a distinct signal from "not probing".
|
||||||
|
getContainerStaleness.mockResolvedValue({
|
||||||
|
...STALE,
|
||||||
|
probe_error: "could not exec in the container",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.probing).toBe(false));
|
||||||
|
expect(result.current.probeSettled).toBe(false);
|
||||||
|
|
||||||
|
getContainerStaleness.mockResolvedValue(STALE);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh();
|
||||||
|
});
|
||||||
|
expect(result.current.probeSettled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crash recovery", () => {
|
||||||
|
it("adopts a run that was still in progress, and polls it to a report", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.running).toBe(true));
|
||||||
|
expect(result.current.recovered).toBe(true);
|
||||||
|
|
||||||
|
getMigrationState.mockResolvedValue(
|
||||||
|
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.report).toEqual(CLEAN), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a finished migration that was never acknowledged", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(
|
||||||
|
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.report).toEqual(CLEAN));
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an interrupted migration instead of leaving it invisible", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||||
|
// Nothing is driving it, so it is not "running" and has no report.
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes an interrupted migration with the options it was given", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||||
|
|
||||||
|
// The resume worked, so the backend cleared the record.
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "awaiting-confirmation" }));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.resume();
|
||||||
|
});
|
||||||
|
// The deltas cannot be recomputed after the swap, so the recorded plan's
|
||||||
|
// options are replayed verbatim rather than re-derived.
|
||||||
|
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", OPTIONS);
|
||||||
|
expect(result.current.interrupted).toBeNull();
|
||||||
|
expect(result.current.report).toEqual(CLEAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a mid-swap container visible when the resume itself fails", async () => {
|
||||||
|
// The old behaviour nulled `interrupted` at the top of `start` and never
|
||||||
|
// looked again, so a failed resume hid a half-migrated container for the
|
||||||
|
// rest of the session — leaving Keep as the only offered action over it.
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.resume();
|
||||||
|
});
|
||||||
|
expect(result.current.report?.phase).toBe("failed");
|
||||||
|
expect(result.current.interrupted?.phase).toBe("interrupted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts the interrupted record a failed fresh run leaves behind", async () => {
|
||||||
|
// `commit_container_snapshot` failing after the swap returns a report and
|
||||||
|
// writes `interrupted`. Both have to reach the UI, or Keep is offered
|
||||||
|
// over a container the app can no longer reason about.
|
||||||
|
getMigrationState.mockResolvedValue(null);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
|
||||||
|
migrateProjectToBase.mockResolvedValue({
|
||||||
|
...CLEAN,
|
||||||
|
phase: "failed",
|
||||||
|
message: "saving it failed. Resume it, or roll back.",
|
||||||
|
});
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start(OPTIONS);
|
||||||
|
});
|
||||||
|
expect(result.current.interrupted?.phase).toBe("interrupted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrecognised phase from a future build rather than crashing", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "quantum-tunnelling" }));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
expect(result.current.interrupted).toBeNull();
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationOptions,
|
||||||
|
MigrationReport,
|
||||||
|
MigrationState,
|
||||||
|
Project,
|
||||||
|
} from "../lib/types";
|
||||||
|
import {
|
||||||
|
MIGRATION_PHASE_AWAITING_CONFIRMATION,
|
||||||
|
MIGRATION_PHASE_IN_PROGRESS,
|
||||||
|
MIGRATION_PHASE_INTERRUPTED,
|
||||||
|
} from "../lib/types";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsettled phases from `MigrationState.phase` (hyphenated, unlike the
|
||||||
|
* outcome phases on `MigrationReport`). Compared as strings on purpose: the
|
||||||
|
* backend types this loosely so an unrecognised value from a future build
|
||||||
|
* cannot crash the UI, and neither can it here — an unknown phase simply
|
||||||
|
* surfaces nothing rather than throwing.
|
||||||
|
*/
|
||||||
|
const IN_PROGRESS = MIGRATION_PHASE_IN_PROGRESS;
|
||||||
|
const INTERRUPTED = MIGRATION_PHASE_INTERRUPTED;
|
||||||
|
const AWAITING = MIGRATION_PHASE_AWAITING_CONFIRMATION;
|
||||||
|
|
||||||
|
export interface ContainerMigration {
|
||||||
|
/** Null until the first probe returns, or when the container has never been created. */
|
||||||
|
staleness: ContainerStaleness | null;
|
||||||
|
probing: boolean;
|
||||||
|
/**
|
||||||
|
* The probe has landed with a complete answer.
|
||||||
|
*
|
||||||
|
* Until it does, `apt_delta`, `verbatim_paths` and `unpreserved_data` are all
|
||||||
|
* "not known", which is indistinguishable from "empty" at every call site
|
||||||
|
* that reads them. Starting a migration in that state means the modal telling
|
||||||
|
* the user there was nothing to copy while the backend quietly skips copying
|
||||||
|
* — so the action is gated on this, not on the probe merely having been
|
||||||
|
* kicked off.
|
||||||
|
*/
|
||||||
|
probeSettled: boolean;
|
||||||
|
/** True while a migration is running — whether we started it or found it. */
|
||||||
|
running: boolean;
|
||||||
|
/** True when the run in progress was recovered from disk, not started here. */
|
||||||
|
recovered: boolean;
|
||||||
|
/**
|
||||||
|
* A migration the app died in the middle of. It is not running and it has no
|
||||||
|
* report: the container is mid-swap until someone resumes or rolls it back.
|
||||||
|
*/
|
||||||
|
interrupted: MigrationState | null;
|
||||||
|
/** Re-enter an interrupted migration. The backend continues the same run. */
|
||||||
|
resume: () => Promise<void>;
|
||||||
|
/** The settled report, kept until the user keeps, rolls back or dismisses it. */
|
||||||
|
report: MigrationReport | null;
|
||||||
|
/** Progress lines from `container-progress`, oldest first. */
|
||||||
|
log: string[];
|
||||||
|
/** The most recent progress line, or null before the first one arrives. */
|
||||||
|
phaseMessage: string | null;
|
||||||
|
/** True while confirm/rollback is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
start: (options: MigrationOptions) => Promise<void>;
|
||||||
|
keep: () => Promise<void>;
|
||||||
|
rollback: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Acknowledge a report there is nothing to keep or roll back.
|
||||||
|
*
|
||||||
|
* It has to reach the backend, not just clear local state: an
|
||||||
|
* `awaiting-confirmation` record that is never resolved comes back on the
|
||||||
|
* next mount *and* makes every future migration refuse with "already has a
|
||||||
|
* finished migration waiting for a decision".
|
||||||
|
*/
|
||||||
|
dismiss: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container base-image migration for one project.
|
||||||
|
*
|
||||||
|
* Three things have to survive a closed modal: the run itself, the progress
|
||||||
|
* log, and the report. A migration takes minutes, so the modal is a *view* onto
|
||||||
|
* this hook rather than the thing that owns the work — closing it must not
|
||||||
|
* cancel anything. The hook lives in `ProjectHome`, above both the modal and
|
||||||
|
* the Overview banner, so either surface can be showing at any point.
|
||||||
|
*
|
||||||
|
* A migration the app died in the middle of is picked up from
|
||||||
|
* `getMigrationState` on mount — as `interrupted`, which is offered for resume,
|
||||||
|
* or as `awaiting-confirmation`, whose report is put back on screen. Without
|
||||||
|
* that, a half-migrated container would look identical to a healthy one, which
|
||||||
|
* is the exact failure mode this whole feature exists to fix.
|
||||||
|
*/
|
||||||
|
export function useContainerMigration(project: Project): ContainerMigration {
|
||||||
|
const projectId = project.id;
|
||||||
|
const [staleness, setStaleness] = useState<ContainerStaleness | null>(null);
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [recovered, setRecovered] = useState(false);
|
||||||
|
const [interrupted, setInterrupted] = useState<MigrationState | null>(null);
|
||||||
|
const [report, setReport] = useState<MigrationReport | null>(null);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const progress = useAppState((s) => s.containerProgress[projectId]);
|
||||||
|
|
||||||
|
// Guards a late response from an earlier project overwriting a newer one.
|
||||||
|
const generation = useRef(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const gen = ++generation.current;
|
||||||
|
if (!project.container_id) {
|
||||||
|
setStaleness(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProbing(true);
|
||||||
|
try {
|
||||||
|
const next = await commands.getContainerStaleness(projectId);
|
||||||
|
if (gen === generation.current) setStaleness(next);
|
||||||
|
} catch {
|
||||||
|
// A probe that cannot reach the container is "we do not know", which is
|
||||||
|
// an absent banner rather than an error one — the same call is retried
|
||||||
|
// whenever the container's status changes.
|
||||||
|
if (gen === generation.current) setStaleness(null);
|
||||||
|
} finally {
|
||||||
|
if (gen === generation.current) setProbing(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.container_id]);
|
||||||
|
|
||||||
|
// Probe staleness when the container settles into a new state. The probe runs
|
||||||
|
// two filesystem walks and is explicitly not for polling, so it is skipped
|
||||||
|
// mid-transition and mid-run — a reading taken while the container is being
|
||||||
|
// swapped describes neither the old system layer nor the new one.
|
||||||
|
const settled = project.status !== "starting" && project.status !== "stopping";
|
||||||
|
useEffect(() => {
|
||||||
|
if (running || !settled) return;
|
||||||
|
void refresh();
|
||||||
|
}, [refresh, settled, running]);
|
||||||
|
|
||||||
|
// Crash recovery: adopt whatever the backend still has on record.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
commands
|
||||||
|
.getMigrationState(projectId)
|
||||||
|
.then((state) => {
|
||||||
|
if (cancelled || !state) return;
|
||||||
|
if (state.phase === IN_PROGRESS) {
|
||||||
|
// Something is still driving it; watch rather than restart.
|
||||||
|
setRunning(true);
|
||||||
|
setRecovered(true);
|
||||||
|
} else if (state.phase === INTERRUPTED) {
|
||||||
|
// Nothing is driving it. The container is mid-swap and will stay that
|
||||||
|
// way until someone resumes — so this must be visible, not silent.
|
||||||
|
setInterrupted(state);
|
||||||
|
} else if (state.phase === AWAITING && state.report) {
|
||||||
|
setReport(state.report);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* No recorded state is the normal case. */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// A recovered run has no promise to await, so poll it to completion.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !recovered) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
commands
|
||||||
|
.getMigrationState(projectId)
|
||||||
|
.then((state: MigrationState | null) => {
|
||||||
|
if (cancelled || state?.phase === IN_PROGRESS) return;
|
||||||
|
setRunning(false);
|
||||||
|
setRecovered(false);
|
||||||
|
// A cleared record means it was confirmed or rolled back elsewhere.
|
||||||
|
if (!state) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.phase === INTERRUPTED) {
|
||||||
|
setInterrupted(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.report) setReport(state.report);
|
||||||
|
void refresh();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* Keep polling; a transient IPC failure is not an outcome. */
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [running, recovered, projectId, refresh]);
|
||||||
|
|
||||||
|
// Accumulate the shared progress line into a scrollback the modal can show.
|
||||||
|
// The store collapses repeats, so identical consecutive apt lines appear once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !progress) return;
|
||||||
|
setLog((prev) =>
|
||||||
|
prev[prev.length - 1] === progress ? prev : [...prev, progress],
|
||||||
|
);
|
||||||
|
}, [progress, running]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the persisted record after a run settles.
|
||||||
|
*
|
||||||
|
* A migration that got past the container swap and then failed leaves the
|
||||||
|
* record at `interrupted` — the container is mid-swap and the only correct
|
||||||
|
* next actions are Resume and Roll back. Without this the hook would show the
|
||||||
|
* failure report's Keep button over a half-migrated container, and a *failed
|
||||||
|
* resume* would clear `interrupted` and never look again, hiding the mid-swap
|
||||||
|
* container for the rest of the session.
|
||||||
|
*/
|
||||||
|
const adoptRecordAfterRun = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const state = await commands.getMigrationState(projectId);
|
||||||
|
setInterrupted(state?.phase === INTERRUPTED ? state : null);
|
||||||
|
} catch {
|
||||||
|
/* Leave whatever we had; a transient IPC failure is not an outcome. */
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (options: MigrationOptions) => {
|
||||||
|
setLog([]);
|
||||||
|
setReport(null);
|
||||||
|
setRecovered(false);
|
||||||
|
setInterrupted(null);
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
const result = await commands.migrateProjectToBase(projectId, options);
|
||||||
|
setReport(result);
|
||||||
|
} catch (e) {
|
||||||
|
// A rejected call means the backend never produced a report. Synthesise
|
||||||
|
// the failed shape so the report surface — not a toast that scrolls
|
||||||
|
// away — is still what tells the user.
|
||||||
|
setReport({
|
||||||
|
phase: "failed",
|
||||||
|
packages_requested: [],
|
||||||
|
packages_installed: [],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [],
|
||||||
|
rollback_available: false,
|
||||||
|
message: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
useAppState.getState().setContainerProgress(projectId, null);
|
||||||
|
await adoptRecordAfterRun();
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, refresh, adoptRecordAfterRun],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enter an interrupted migration. The backend continues that run rather
|
||||||
|
* than starting a new one, and the recorded options are replayed as-is — the
|
||||||
|
* deltas cannot be recomputed once the container has already been swapped.
|
||||||
|
*/
|
||||||
|
const resume = useCallback(async () => {
|
||||||
|
const pending = interrupted;
|
||||||
|
if (!pending) return;
|
||||||
|
await start(pending.options);
|
||||||
|
}, [interrupted, start]);
|
||||||
|
|
||||||
|
const keep = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await commands.confirmMigration(projectId);
|
||||||
|
setReport(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not discard the rollback image for “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.name, refresh, pushToast]);
|
||||||
|
|
||||||
|
const rollback = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await commands.rollbackMigration(projectId);
|
||||||
|
setReport(null);
|
||||||
|
setInterrupted(null);
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: `“${project.name}” is back on its previous system layer.`,
|
||||||
|
detail:
|
||||||
|
"Volumes were not touched, so anything written to your home directory or workspace during the update is still there.",
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Rollback failed for “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.name, refresh, pushToast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss resolves the record; it is not a local hide.
|
||||||
|
*
|
||||||
|
* `confirm_migration` is the backend's "this decision is made": it drops the
|
||||||
|
* rollback tag (there is none in this case), deletes the staged payload and
|
||||||
|
* removes the state file. Skipping it left an `awaiting-confirmation` record
|
||||||
|
* on disk that reappeared on every mount and made `migrate_project_to_base`
|
||||||
|
* refuse forever — recoverable only by deleting JSON by hand.
|
||||||
|
*/
|
||||||
|
const dismiss = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await commands.confirmMigration(projectId);
|
||||||
|
setReport(null);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not clear the update record for “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.name, pushToast]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
staleness,
|
||||||
|
probing,
|
||||||
|
probeSettled: !probing && staleness !== null && !staleness.probe_error,
|
||||||
|
running,
|
||||||
|
recovered,
|
||||||
|
interrupted,
|
||||||
|
report,
|
||||||
|
log,
|
||||||
|
phaseMessage: log.length > 0 ? log[log.length - 1] : null,
|
||||||
|
busy,
|
||||||
|
start,
|
||||||
|
resume,
|
||||||
|
keep,
|
||||||
|
rollback,
|
||||||
|
dismiss,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useDocker } from "./useDocker";
|
||||||
|
|
||||||
|
const checkDocker = vi.fn();
|
||||||
|
const checkImageExists = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
checkDocker: () => checkDocker(),
|
||||||
|
checkImageExists: () => checkImageExists(),
|
||||||
|
buildImage: vi.fn(),
|
||||||
|
pullImage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||||
|
|
||||||
|
const setDockerAvailable = vi.fn();
|
||||||
|
const setImageExists = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../store/appState", () => ({
|
||||||
|
useAppState: (selector: (s: unknown) => unknown) =>
|
||||||
|
selector({
|
||||||
|
dockerAvailable: false,
|
||||||
|
setDockerAvailable,
|
||||||
|
imageExists: false,
|
||||||
|
setImageExists,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Let the interval fire and its awaited body settle. */
|
||||||
|
const tick = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useDocker.startDockerPolling", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
checkImageExists.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs onAvailable once, after Docker is marked available and the image re-checked", async () => {
|
||||||
|
checkDocker.mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||||
|
const onAvailable = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDocker());
|
||||||
|
act(() => {
|
||||||
|
result.current.startDockerPolling(onAvailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(onAvailable).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(setDockerAvailable).toHaveBeenCalledWith(true);
|
||||||
|
expect(setImageExists).toHaveBeenCalledWith(true);
|
||||||
|
expect(onAvailable).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Polling stopped, so no second invocation.
|
||||||
|
await tick();
|
||||||
|
expect(onAvailable).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still works without a callback and can be cancelled by its cleanup", async () => {
|
||||||
|
checkDocker.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDocker());
|
||||||
|
let stop: () => void = () => {};
|
||||||
|
act(() => {
|
||||||
|
stop = result.current.startDockerPolling();
|
||||||
|
});
|
||||||
|
act(() => stop());
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(checkDocker).not.toHaveBeenCalled();
|
||||||
|
expect(setDockerAvailable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,7 +61,15 @@ export function useDocker() {
|
|||||||
|
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const startDockerPolling = useCallback(() => {
|
/**
|
||||||
|
* Poll until Docker appears, then stop.
|
||||||
|
*
|
||||||
|
* `onAvailable` runs exactly once, after `dockerAvailable` is set and the
|
||||||
|
* image has been re-checked. It exists because a session that started before
|
||||||
|
* the daemon was up otherwise never does the "Docker is up" work — status
|
||||||
|
* reconciliation, interrupted-migration recovery, loading the project list.
|
||||||
|
*/
|
||||||
|
const startDockerPolling = useCallback((onAvailable?: () => void | Promise<void>) => {
|
||||||
// Don't start if already polling
|
// Don't start if already polling
|
||||||
if (pollingRef.current) return () => {};
|
if (pollingRef.current) return () => {};
|
||||||
|
|
||||||
@@ -79,6 +87,11 @@ export function useDocker() {
|
|||||||
} catch {
|
} catch {
|
||||||
setImageExists(false);
|
setImageExists(false);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await onAvailable?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Docker-available callback failed:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Still not available, keep polling
|
// Still not available, keep polling
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
|
import { useAppState, homeTabKey, terminalTabKey } from "../store/appState";
|
||||||
|
|
||||||
|
vi.mock("./useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = homeTabKey("p1");
|
||||||
|
const S1 = terminalTabKey("s1");
|
||||||
|
const S2 = terminalTabKey("s2");
|
||||||
|
|
||||||
|
const order = () => useAppState.getState().tabOrder;
|
||||||
|
|
||||||
|
/** Press a chord, from whatever is focused. */
|
||||||
|
function press(key: string, { shift = false } = {}) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: shift, bubbles: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus a real element of the given kind, inside `parent` if given. */
|
||||||
|
function focus(tag: "input" | "textarea", parentClass?: string): HTMLElement {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (parentClass) {
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
parent.className = parentClass;
|
||||||
|
parent.appendChild(el);
|
||||||
|
document.body.appendChild(parent);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
el.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAppState.setState({ tabOrder: [HOME, S1, S2], activeTabKey: S1, activeSessionId: "s1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ctrl+Shift+←/→", () => {
|
||||||
|
it("moves the active tab along the strip", () => {
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
|
||||||
|
press("ArrowRight", { shift: true });
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves word-wise selection alone in a text field", () => {
|
||||||
|
// Ctrl+Shift+←/→ already means "extend the selection by a word" in every
|
||||||
|
// input in the app — the rename field, Config fields, Settings fields.
|
||||||
|
// Taking it there would break selection *and* silently reorder tabs.
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
focus("input");
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still moves tabs from the terminal, whose focus lives in a textarea", () => {
|
||||||
|
// xterm keeps a hidden textarea focused as its input-method shim. It is
|
||||||
|
// not a field anyone edits word-wise, and the terminal is where these
|
||||||
|
// shortcuts matter most, so it is not treated as one.
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
focus("textarea", "xterm xterm-helper-textarea-host");
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without Shift — that chord is readline's word motion", () => {
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
|
||||||
|
press("ArrowLeft");
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
});
|
||||||