Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b | ||
|
|
e379c58684 | ||
|
|
ab747ce53d | ||
|
|
85ea3956e8 | ||
|
|
5bd80a05bc | ||
|
|
f239fa1c82 | ||
|
|
5b18ce804f | ||
|
|
63f3c54b95 | ||
|
|
f68d9c5788 | ||
|
|
bd72781482 | ||
|
|
1207a21aae | ||
|
|
a41d93ea46 | ||
|
|
d73096c937 | ||
|
|
57b6b71772 | ||
|
|
77567ac2ae | ||
|
|
247f03b48c | ||
|
|
31c73adb13 | ||
|
|
4fdfed7955 | ||
|
|
7a5823cb2b | ||
|
|
a5bcc462a7 | ||
|
|
c3f92674b1 | ||
|
|
584fcdd837 | ||
|
|
2c014fd752 | ||
|
|
43e9959e40 | ||
|
|
e05156fd0e | ||
|
|
aca6c49e3c | ||
|
|
0aa8315514 | ||
|
|
29fd7de909 | ||
|
|
fdc161fd9c | ||
|
|
98a6c8fd56 | ||
|
|
763af91042 | ||
|
|
c71e54a35f | ||
|
|
03384409e7 | ||
|
|
b41077e799 | ||
|
|
c6bb7fdf1d | ||
|
|
704d3b8f79 | ||
|
|
ca0f944712 | ||
|
|
2de00b3c55 | ||
|
|
eb1324cb16 | ||
|
|
d42b741337 | ||
|
|
cc5f691677 | ||
|
|
7d00390e1f | ||
|
|
cf3b021c72 | ||
|
|
d95ba54a69 | ||
|
|
01a2f6aec8 | ||
|
|
f68d10d5c2 | ||
|
|
0ac4e5030c | ||
|
|
d0bb631d4d | ||
|
|
657c61939f | ||
|
|
401e28a658 | ||
|
|
7c39e3cf11 | ||
|
|
ccdfc52dce | ||
|
|
2e661979ea | ||
|
|
59d89bcd1b | ||
|
|
26adccce5b | ||
|
|
876ba8a8fc | ||
|
|
5cd528a4ef | ||
|
|
c3fc029b1d | ||
|
|
dc253e8da0 |
@@ -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 }}
|
||||||
@@ -47,8 +47,12 @@ jobs:
|
|||||||
echo "Latest matching tag: ${LATEST_TAG}"
|
echo "Latest matching tag: ${LATEST_TAG}"
|
||||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||||
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}"
|
||||||
@@ -357,6 +361,77 @@ jobs:
|
|||||||
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
Write-Host "Patched version to $version"
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install MSVC C++ build tools
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
|
||||||
|
rem This job previously assumed a hand-provisioned runner; a runner
|
||||||
|
rem without them registers fine, advertises windows-latest, accepts the
|
||||||
|
rem job, downloads the whole crate graph and only then fails at link
|
||||||
|
rem time with "linker `link.exe` not found".
|
||||||
|
rem
|
||||||
|
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
|
||||||
|
rem installing is enough - no dev-shell activation needed here.
|
||||||
|
rem
|
||||||
|
rem Delayed expansion is required: %VAR% inside a parenthesised block
|
||||||
|
rem is substituted when the block is PARSED, not when it runs, so both
|
||||||
|
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
set "VCPATH="
|
||||||
|
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||||
|
if exist "%VSWHERE%" (
|
||||||
|
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
|
||||||
|
)
|
||||||
|
if defined VCPATH (
|
||||||
|
echo MSVC build tools already present at !VCPATH!
|
||||||
|
) else (
|
||||||
|
echo MSVC build tools not found - installing Visual Studio Build Tools
|
||||||
|
curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
|
||||||
|
"%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
|
||||||
|
set "VSEXIT=!ERRORLEVEL!"
|
||||||
|
del "%TEMP%\vs_BuildTools.exe" 2>nul
|
||||||
|
rem 3010 means installed, reboot pending - a success for our purposes.
|
||||||
|
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
|
||||||
|
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Visual Studio Build Tools installed
|
||||||
|
)
|
||||||
|
endlocal
|
||||||
|
|
||||||
|
- name: Work around WOW64 redirection for 32-bit bundlers
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
rem Tauri downloads its bundlers - candle.exe, light.exe and
|
||||||
|
rem makensis.exe - and every one of them is 32-bit. When the runner
|
||||||
|
rem runs as SYSTEM its %LOCALAPPDATA% is under
|
||||||
|
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
|
||||||
|
rem serves any 32-bit process reading System32 from SysWOW64 instead -
|
||||||
|
rem where those directories do not exist. The bundlers then cannot see
|
||||||
|
rem their own folder: candle exits 0x80131700 and makensis reports
|
||||||
|
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
|
||||||
|
rem only "failed to run candle.exe", which is why this is worth a
|
||||||
|
rem comment this long.
|
||||||
|
rem
|
||||||
|
rem Junctioning the SysWOW64 view onto the System32 originals makes the
|
||||||
|
rem redirected path resolve to the same files. A runner running as a
|
||||||
|
rem normal user has a profile outside System32 and skips all of this.
|
||||||
|
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
|
||||||
|
if errorlevel 1 goto skipwow
|
||||||
|
|
||||||
|
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||||
|
|
||||||
|
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
|
||||||
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
|
||||||
|
|
||||||
|
echo WOW64 junctions in place for the SYSTEM profile
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:skipwow
|
||||||
|
echo Runner profile is outside System32 - WOW64 junctions not needed
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
run: |
|
run: |
|
||||||
where rustup >nul 2>&1 && (
|
where rustup >nul 2>&1 && (
|
||||||
@@ -416,14 +491,26 @@ jobs:
|
|||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
||||||
|
rem makensis.exe - is 32-bit. A runner running as SYSTEM has
|
||||||
|
rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
|
||||||
|
rem WOW64 redirection sends 32-bit processes reading System32 to
|
||||||
|
rem SysWOW64, so they cannot see their own directory: candle exits
|
||||||
|
rem 0x80131700 and makensis reports "Unable to start child process,
|
||||||
|
rem error 0x2".
|
||||||
|
rem
|
||||||
|
rem The build VM carries junctions from the SysWOW64 view of
|
||||||
|
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
|
||||||
|
rem System32 originals, which makes the redirected view resolve. A
|
||||||
|
rem runner running as a normal user needs no such patch.
|
||||||
|
cargo tauri build --bundles msi,nsis
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
mkdir artifacts
|
mkdir artifacts
|
||||||
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1
|
||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
|
|||||||
@@ -56,38 +56,278 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
|
|
||||||
### Frontend Structure (`app/src/`)
|
### Frontend Structure (`app/src/`)
|
||||||
|
|
||||||
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
||||||
|
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
||||||
|
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
||||||
|
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||||
|
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||||
|
`moveTab` deliberately does not activate what it moves.
|
||||||
|
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
||||||
|
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
||||||
|
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
|
||||||
|
default handler types into any text field the drag is released over.
|
||||||
|
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
|
||||||
|
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
||||||
|
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
||||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
- **`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
|
||||||
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||||
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||||
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
|
and the editors reused by Project Home
|
||||||
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
|
modals — see "UI conventions" below.
|
||||||
|
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
||||||
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||||
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
|
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||||
|
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
|
||||||
|
|
||||||
|
### UI conventions
|
||||||
|
|
||||||
|
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
|
||||||
|
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
|
||||||
|
previously had ~12 hand-rolled modals; they were consolidated deliberately.
|
||||||
|
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
|
||||||
|
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
|
||||||
|
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||||
|
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
||||||
|
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
||||||
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
||||||
|
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
|
||||||
|
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
|
||||||
|
why tab-moving takes Shift.
|
||||||
|
|
||||||
### Backend Structure (`app/src-tauri/src/`)
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
|
- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`.
|
||||||
|
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
|
||||||
|
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||||
|
`auth_token_commands.rs`.
|
||||||
|
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
|
||||||
|
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
|
||||||
|
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
|
||||||
|
API via `socat`. Opt-in per project.
|
||||||
|
- **`browser_view/`** — Watch and take over the browser Claude drives with Playwright inside the
|
||||||
|
container. Runs Playwright's own dashboard (`browser.bind()` + `playwright-cli show`) in the
|
||||||
|
container and fronts it with a **token-gated** loopback proxy. Deliberately does **not** reuse
|
||||||
|
the auth bridge's `PortForward`, which binds an unauthenticated port — fine for a throwaway
|
||||||
|
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
||||||
|
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
||||||
|
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
||||||
|
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
|
||||||
|
can be watched on another monitor or pinned on top while the main window is used for work.
|
||||||
|
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
|
||||||
|
not give it one; the app CSP does not apply, because it is a top-level document rather than a
|
||||||
|
frame, and the token gate is what protects the port in both cases; and the window is owned by
|
||||||
|
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
|
||||||
|
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||||
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||||
|
the browser.
|
||||||
|
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||||
|
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||||
|
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
|
||||||
|
container-side browser closes the loop with no host round trip and no auth bridge) and dev
|
||||||
|
servers on container loopback. **Verified: a second client cannot join a bound browser** —
|
||||||
|
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||||
|
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||||
|
is the only process that can drive, which is why the helper is resident and why live resize
|
||||||
|
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||||
|
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||||
|
no second listener — and a re-open with a helper already up *navigates* rather than
|
||||||
|
relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
|
||||||
|
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
|
||||||
|
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||||
|
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||||
|
continuously and each one costs a container exec.
|
||||||
|
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||||
|
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||||
|
container stopped, process exited.
|
||||||
|
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
|
||||||
|
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
|
||||||
|
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does
|
||||||
|
not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the
|
||||||
|
wrapper alone reports a current build as "predates `browser.bind()`".
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that binds,
|
||||||
|
but never `@playwright/cli`, which is the viewer. Never offer it as a setup route — only as
|
||||||
|
what binds sessions automatically once Playwright is present.
|
||||||
|
- **`install.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is
|
||||||
|
*not* a bind mount — project directories are mounted at `/workspace/{mount_name}` — so this
|
||||||
|
touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned),
|
||||||
|
and is on the module resolution path for scripts in the project. Browsers go to
|
||||||
|
`~/.cache/ms-playwright` as `claude`, i.e. the home volume.
|
||||||
|
- **Current base images ship Chromium's shared libraries; older ones do not** — and a project
|
||||||
|
keeps the base image it was first built from until it is migrated, so "older" is the normal
|
||||||
|
case. Without them `playwright install chromium` downloads a browser that cannot launch, which
|
||||||
|
is why installing Chrome via apt looks like a fix. `install.rs` asks
|
||||||
|
`install-deps --dry-run` first and skips the apt step when the answer is "all present",
|
||||||
|
*saying so* in the progress stream. Do not decide this by probing for library names: the
|
||||||
|
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
|
||||||
|
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
|
||||||
|
everything is installed and when Playwright has no list for the platform — match on its
|
||||||
|
output, not its exit code. Either way the action ends by *actually launching* the browser to
|
||||||
|
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
|
||||||
|
offered.
|
||||||
- **`docker/`** — Docker API layer using bollard:
|
- **`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` — PTY exec sessions with bidirectional stdin/stdout streaming
|
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||||
|
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||||
- `image.rs` — Image build/pull with progress streaming
|
- `image.rs` — Image build/pull with progress streaming
|
||||||
|
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||||
|
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||||
|
Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume
|
||||||
|
it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach
|
||||||
|
it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`).
|
||||||
|
`GatewayBinding` derives the bind address and the advertised `base_url` together so they
|
||||||
|
cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls —
|
||||||
|
in front of a container config holding a billed provider key. It also **always** sets a
|
||||||
|
LiteLLM `master_key`, since LiteLLM without one accepts any key.
|
||||||
|
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
|
||||||
|
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
|
||||||
|
crash-recovery state machine. See "Base-image migration" below.
|
||||||
|
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||||
|
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||||
|
users have migrated.
|
||||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||||
- `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.
|
||||||
|
|
||||||
### 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}`) so OAuth tokens survive even container resets.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
|
|
||||||
|
**Reset is the exception and it is destructive.** `rebuild_project_container` calls
|
||||||
|
`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`,
|
||||||
|
`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is
|
||||||
|
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||||
|
preserving credentials.
|
||||||
|
|
||||||
|
### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`)
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it — so without an explicit act, a project stays on the base image it
|
||||||
|
was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a
|
||||||
|
security update. Migration is the non-destructive way out; Reset is the destructive one.
|
||||||
|
|
||||||
|
- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records
|
||||||
|
the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long
|
||||||
|
comment there. Comparing it would recreate every project *from its own snapshot* on the next base
|
||||||
|
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||||
|
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||||
|
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||||
|
- **`: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
|
||||||
|
|
||||||
@@ -95,7 +335,20 @@ 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
|
||||||
|
|
||||||
@@ -108,8 +361,28 @@ Per-project, independently configured:
|
|||||||
|
|
||||||
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
||||||
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
||||||
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
|
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
|
||||||
|
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
|
||||||
|
need an entry there — adding one is not required and none exists for any app command.
|
||||||
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
||||||
|
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
|
||||||
|
**label-based** — it does not diff the container's env. If a new setting affects the container's
|
||||||
|
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
||||||
|
and compare it there, or the change will silently not take effect until some unrelated setting
|
||||||
|
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
||||||
|
(`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the
|
||||||
|
reasoning is in the comment beside the check.)
|
||||||
|
- **Always write a `triple-c.*` label explicitly, even when the value is empty.** Docker merges an
|
||||||
|
image's labels into a container's at creation, and `docker commit` copies container labels onto
|
||||||
|
the snapshot image — so a label stamped once rides that snapshot into *every* future container
|
||||||
|
forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not
|
||||||
|
been written by any code since the MCP feature was removed, yet a snapshot image was found still
|
||||||
|
carrying a non-empty one, which made its one-shot recreation shim recreate that project on every
|
||||||
|
single start. Writing the key explicitly overrides the inherited value — the same defence
|
||||||
|
`MANAGED_AUTH_KEYS` applies to env vars.
|
||||||
|
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
||||||
|
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||||
|
`models/project.rs` for anything that should default to true.
|
||||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# Triple-C Design & Product Review
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Version reviewed:** 0.3.0 · **Reviewer:** Fable 5
|
||||||
|
|
||||||
|
Scope: `app/src/` (App, layout, projects, settings, terminal, ui, store, index.css),
|
||||||
|
README/CLAUDE.md/TODO.md, the four repo screenshots, and `triple-c-app-logov2.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary verdict
|
||||||
|
|
||||||
|
The bones are good. The floating-panel layout reads clean, the GitHub-dark palette is
|
||||||
|
inoffensive, and terminal-as-centerpiece is correct for this product.
|
||||||
|
|
||||||
|
The two real problems are structural, and they are the same problem seen from two sides:
|
||||||
|
**the project — the app's actual unit of work — has no room to live.** Everything about a
|
||||||
|
project (backend auth, mounts, git identity, env vars, ports, Claude settings, file
|
||||||
|
manager) is stuffed into a ~280px sidebar card (`ProjectCard.tsx`, 1,257 lines) that
|
||||||
|
sprays out seven modals to compensate.
|
||||||
|
|
||||||
|
`screenshot_for_fix/project_config_run_off.png` is not a bug to patch. It is the
|
||||||
|
architecture reporting that the config does not fit where it lives. Fixing that one thing
|
||||||
|
also solves the modal pile, the density problems, *and* creates the surface where newer
|
||||||
|
Claude Code concepts belong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Visual & interaction design
|
||||||
|
|
||||||
|
### A1. Tokens: coherent but thin, with one real contrast failure
|
||||||
|
|
||||||
|
`index.css` is GitHub Primer dark, verbatim (`#0d1117 / #161b22 / #21262d / #30363d /
|
||||||
|
#8b949e / #58a6ff`). Defensible — familiar, calm, terminal-adjacent — but the token layer
|
||||||
|
stops at 11 variables. Roles the code is already faking ad hoc:
|
||||||
|
|
||||||
|
- **No elevation/overlay token.** Modals reuse `--bg-secondary`, so a modal over the
|
||||||
|
sidebar is the same color as the sidebar. Add `--bg-overlay: #1c2128` and
|
||||||
|
`--shadow-overlay`.
|
||||||
|
- **No muted-accent tokens.** The code hand-rolls `bg-yellow-500/20 text-yellow-400`,
|
||||||
|
`bg-blue-500/20 text-blue-400`, `--warning/15`, `--error/10`. Add `--accent-muted`,
|
||||||
|
`--warning-muted`, `--error-muted`, `--success-muted`. Those raw Tailwind palette colors
|
||||||
|
are the only two places the token system leaks.
|
||||||
|
- **Radius drift:** `rounded` (4px), `rounded-lg` (8px), plus hardcoded 3px/6px in help
|
||||||
|
styles. Pick two: 6px controls, 8px panels.
|
||||||
|
|
||||||
|
**Contrast bug (concrete):** white text on `--accent #58a6ff` is ~**2.5:1** — fails WCAG
|
||||||
|
AA. That is the primary button ("Add Project"), the "Update" pill, and more. Primer solves
|
||||||
|
this with two accents: keep `#58a6ff` as the *foreground/link* accent and add
|
||||||
|
`--accent-emphasis: #1f6feb` for filled buttons (white on `#1f6feb` ≈ 4.7:1).
|
||||||
|
|
||||||
|
Same story for `bg-[var(--success)] text-white` ON toggles — `#3fb950` + white ≈ **2.1:1**,
|
||||||
|
the worst offender in the app.
|
||||||
|
|
||||||
|
What passes: `--text-secondary #8b949e` on `#161b22` ≈ 5.8:1, fine even at 12px.
|
||||||
|
`--warning #d29922` ≈ 7:1. But `disabled:opacity-50` on secondary text drops to ~2.4:1 —
|
||||||
|
and since the entire config form is disabled while the container runs, **the most common
|
||||||
|
state of the form is illegible.** Use a dedicated `--text-disabled: #6e7681` instead of
|
||||||
|
opacity.
|
||||||
|
|
||||||
|
### A2. Type and density: everything is 12px
|
||||||
|
|
||||||
|
Roughly 90% of the UI is `text-xs`. Hierarchy is carried almost entirely by weight plus a
|
||||||
|
single `text-lg` modal title. Forms feel cramped rather than dense — density is
|
||||||
|
information per pixel, not small type.
|
||||||
|
|
||||||
|
Proposed scale with roles: **11px** uppercase section labels (already used, keep) ·
|
||||||
|
**12px** secondary/meta · **13px** default UI/body/form values · **14px** panel headers ·
|
||||||
|
**16px** view titles.
|
||||||
|
|
||||||
|
Path strings in mono are a nice identity touch — extend mono to all machine values (model
|
||||||
|
IDs, ports, digests), which the Bedrock/Ollama forms currently render in the UI face.
|
||||||
|
|
||||||
|
The outer chrome spends generously while content starves: `App.tsx` wraps everything in
|
||||||
|
`p-6 gap-4`, then the config form gets ~180px-wide inputs for AWS secret keys. Keep the
|
||||||
|
floating-island look; `p-3 gap-3` buys content ~24px horizontally and the terminal two
|
||||||
|
more rows.
|
||||||
|
|
||||||
|
### A3. The project card is three components wearing one div
|
||||||
|
|
||||||
|
`ProjectCard` is simultaneously a list row, a command strip, and the entire settings form.
|
||||||
|
|
||||||
|
- **Selection and disclosure are conflated.** Clicking a row both selects it and expands an
|
||||||
|
accordion in place, shoving the other projects down. The 06-28 screenshot shows 18
|
||||||
|
projects — this jank is daily.
|
||||||
|
- **Actions are unstyled text links.** `ActionButton` renders `text-xs px-2 py-0.5` colored
|
||||||
|
text with no border or background, so Start/Stop/Terminal/Shell/Files/Backup/Config/Remove
|
||||||
|
read as a wrapping line of links. Worse, **Remove (destructive, red) wraps directly next
|
||||||
|
to Config** with a ~20px hit target.
|
||||||
|
- **Double-click-to-rename** is undiscoverable and keyboard/touch-inaccessible.
|
||||||
|
- **27 hover-only `<Tooltip>` markers in ProjectCard alone.** When a form needs 27 tooltips,
|
||||||
|
the form is the problem.
|
||||||
|
|
||||||
|
### A4. Modals: eight is a pattern smell, and none are real dialogs
|
||||||
|
|
||||||
|
Hanging off ProjectCard: EnvVars, PortMappings, ClaudeInstructions, ClaudeCodeSettings,
|
||||||
|
ContainerProgress, FileManager, ConfirmRemove — plus AddProject, three reused from
|
||||||
|
SettingsPanel, and Update/ImageUpdate/Help from TopBar.
|
||||||
|
|
||||||
|
Each reimplements the overlay div, Escape handler, and click-outside logic by hand. **None
|
||||||
|
has `role="dialog"`, `aria-modal`, a focus trap, or focus restore** — zero hits for
|
||||||
|
`role=`, `aria-modal`, or `tabIndex` across `components/`.
|
||||||
|
|
||||||
|
The pattern is wrong not because modals are bad, but because these are not modal *tasks*.
|
||||||
|
Env vars, ports, instructions, and Claude settings are all "edit part of the project
|
||||||
|
config" — a detail view's job.
|
||||||
|
|
||||||
|
- Legitimately modal: **ConfirmRemove**, **AddProject**.
|
||||||
|
- **FileManager** wants to be a main-area tab, not a 42rem popup.
|
||||||
|
- **ContainerProgressModal actively hurts:** starting a container blocks the entire app
|
||||||
|
behind an overlay for an operation designed to be routine. Replace with inline row state
|
||||||
|
plus an error toast.
|
||||||
|
- Whatever survives should be one shared `<Modal>` primitive with focus trap + ARIA.
|
||||||
|
|
||||||
|
### A5. Keyboard and focus: currently unsupported
|
||||||
|
|
||||||
|
For a tool whose centerpiece is a keyboard-driven terminal, the chrome is mouse-only.
|
||||||
|
|
||||||
|
- Inputs use `focus:outline-none` with only a low-contrast border swap; **buttons have no
|
||||||
|
focus style at all** — tabbing through the sidebar is invisible.
|
||||||
|
- One-line fix: add `--focus-ring: #58a6ff` and
|
||||||
|
`:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }`
|
||||||
|
- No shortcuts for constant actions: `Ctrl+T` new terminal, `Ctrl+Tab`/`Ctrl+1..9` switch,
|
||||||
|
`Ctrl+W` close, `Ctrl+P` project switcher. The only shortcut in the app is the STT mic.
|
||||||
|
- Hit targets below 24px: tab close "×" (~14px), Tooltip "?" (14px), Browse "...". The
|
||||||
|
status bar is `h-6` yet hosts two interactive controls.
|
||||||
|
|
||||||
|
### A6. Status communication
|
||||||
|
|
||||||
|
Three disconnected dot systems (TopBar Docker/Image, per-project status, StatusBar counts),
|
||||||
|
all 8px and color-only.
|
||||||
|
|
||||||
|
- **Stopped (gray) and error (red) differ only by hue**, and Docker-unavailable renders the
|
||||||
|
same gray as Docker-still-being-checked (`dockerAvailable === null` and `false` both fall
|
||||||
|
through). An outage should be loud; unknown should pulse.
|
||||||
|
- Color-only encoding fails colorblind users. Add shape or text — `● Running`, `○ Stopped`,
|
||||||
|
`⚠ Error`. The words are already in the model.
|
||||||
|
- Raw `String(e)` errors dumped into a 12px card line; bollard errors are long. Errors need
|
||||||
|
a home: toast plus expandable detail.
|
||||||
|
- The TopBar tab strip is visually disconnected from the terminal it controls. Move tabs
|
||||||
|
onto the terminal panel's top edge so the active tab connects to its content.
|
||||||
|
|
||||||
|
### A7. Empty and first-run states
|
||||||
|
|
||||||
|
`WelcomeScreen` is three lines of gray text with no affordance — "Add a project from the
|
||||||
|
sidebar" *describes* a button instead of *being* one. This is also where brand could exist:
|
||||||
|
the orange sun-gear logo appears nowhere in the UI and shares no DNA with the blue-on-
|
||||||
|
graphite chrome.
|
||||||
|
|
||||||
|
Make it an onboarding checklist reusing state already tracked:
|
||||||
|
✓ Docker detected → ✓ Image pulled → **[ Add your first project ]** → open terminal.
|
||||||
|
The same pattern fixes the "image missing" case, today just a gray dot in the corner.
|
||||||
|
|
||||||
|
### A8. Dark-only: keep it
|
||||||
|
|
||||||
|
Right call. Terminal-first developer tool, xterm content is dark, audience expects it. The
|
||||||
|
tokens make a light theme cheap later. Don't spend on it now — but keep discipline that no
|
||||||
|
color bypasses the token layer.
|
||||||
|
|
||||||
|
### A9. Iconography
|
||||||
|
|
||||||
|
Mixed: hand-inlined Feather-style SVGs in the sidebar rail, text glyphs elsewhere ("×",
|
||||||
|
"?", "...", "+", "✓", "✕"). Adopt `lucide-react` — same stroke style already being
|
||||||
|
imitated, tree-shakeable — and replace the text glyphs. It also supplies the per-concept
|
||||||
|
icons Part B needs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — Information architecture & product concepts
|
||||||
|
|
||||||
|
### B1. The diagnosis
|
||||||
|
|
||||||
|
Current IA: `Projects | MCP | Settings` in a sidebar, terminal in main, project detail
|
||||||
|
crammed into the list.
|
||||||
|
|
||||||
|
Deleting the MCP tab was correct — but **the lesson matters more than the freed slot.
|
||||||
|
MCP died as a Triple-C feature because Claude Code absorbed it.** Hooks, skills, agents,
|
||||||
|
plugins, output styles, and statusline are all the same species: files under `.claude/`
|
||||||
|
that Claude Code manages natively with its own TUIs (`/agents`, `/hooks`, `/plugins`). If
|
||||||
|
Triple-C builds form editors for them, it loses the same race again and becomes exactly
|
||||||
|
what it should fear — a settings-file editor with a GUI skin.
|
||||||
|
|
||||||
|
What Claude Code *cannot* do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it.** The config volume, the workspace mounts, the lifecycle, the
|
||||||
|
scheduler already shipping in every image, and the fleet view across many projects.
|
||||||
|
|
||||||
|
> **Principle: Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Sessions, checkpoints, background tasks, scheduled tasks, capability inventory → surface
|
||||||
|
them, read from the volume, launch into the terminal. Hook/skill/agent *editing* →
|
||||||
|
deep-link into the terminal, don't rebuild.
|
||||||
|
|
||||||
|
### B2. Proposed IA: three nouns
|
||||||
|
|
||||||
|
**Project** (a sandboxed workspace) · **Session** (a resumable conversation) ·
|
||||||
|
**Library** (reusable capabilities pushed into projects). Everything is one of these, or
|
||||||
|
Settings.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar: ⌂ api-server │ ▣ api-server ✕ │ ▣ api (bash) ✕ │ ● ● ? │
|
||||||
|
├─────────────┬──────────────────────────────────────────────────────┤
|
||||||
|
│ ◤ Projects │ MAIN AREA — a tab strip of two tab kinds: │
|
||||||
|
│ ● api-serv │ ⌂ project-home tabs ▣ terminal tabs │
|
||||||
|
│ ○ blog │ │
|
||||||
|
│ ● data-pipe│ ⌂ api-server ● Running · 2h 14m │
|
||||||
|
│ … │ ┌─────────┬──────────┬────────────┬────────┐ │
|
||||||
|
│ ◧ Library │ │Overview │ Sessions │ Automation │ Config │ │
|
||||||
|
│ ⚙ Settings │ └─────────┴──────────┴────────────┴────────┘ │
|
||||||
|
├─────────────┴──────────────────────────────────────────────────────┤
|
||||||
|
│ StatusBar: 18 projects · 8 running · 4 terminals 🎤 ↓Jump │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sidebar** becomes a pure list plus nav rail. Rows carry name, path, status dot, and on
|
||||||
|
hover a play/stop and terminal button. Clicking opens (or focuses) that project's
|
||||||
|
**Project Home** tab. The freed MCP slot becomes **Library**.
|
||||||
|
- **Main area** hosts two tab kinds: terminals (as today) and project-home tabs, like VS
|
||||||
|
Code's Settings tab. The terminal stays the centerpiece; Project Home is one keystroke
|
||||||
|
away rather than a layer on top.
|
||||||
|
- **All seven config modals dissolve** into the Config tab, full-width, grouped:
|
||||||
|
*Workspace* (folders/mounts), *Model* (backend + auth), *Access* (git/SSH/env/ports),
|
||||||
|
*Runtime* (docker access, sandbox, permission mode, Mission Control). Room for visible
|
||||||
|
helper text kills most of the 27 tooltips. Save-on-blur stays but gains a visible
|
||||||
|
"Saved ✓ / Failed" indicator — today failures go only to `console.error`, which is
|
||||||
|
silent data loss.
|
||||||
|
|
||||||
|
#### Project Home — Overview tab
|
||||||
|
|
||||||
|
```
|
||||||
|
api-server ● Running · started 2h ago
|
||||||
|
[ Stop ] [ Open Claude Terminal ] [ Shell ] [ Files ] [⋯ menu]
|
||||||
|
|
||||||
|
Permission mode ( Plan ) ( Default ) ( Accept Edits ) (▮ Bypass ▮)
|
||||||
|
Sandbox ON — bubblewrap isolation Backend Anthropic
|
||||||
|
|
||||||
|
CAPABILITIES (read from container volume)
|
||||||
|
◆ Skills 7 ◆ Agents 3 ◆ Hooks 2 ◆ Plugins 1 ◆ Commands 5
|
||||||
|
└ click any → drawer listing names/descriptions,
|
||||||
|
[Manage in terminal] → opens claude with /agents etc.
|
||||||
|
|
||||||
|
RECENT SESSIONS SCHEDULED TASKS
|
||||||
|
"Refactor OAuth flow" 2h ago [Resume] nightly-review 0 3 * * *
|
||||||
|
"Fix flaky CI test" 1d ago [Resume] [2 notifications]
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. The four concepts worth building
|
||||||
|
|
||||||
|
**1. Sessions & Resume — the flagship.** The stop/start container model creates a problem
|
||||||
|
plain Claude Code doesn't have: stop a container, come back Tuesday, and "which
|
||||||
|
conversation was I in?" is buried in the volume. Read session metadata via `docker exec`
|
||||||
|
(the exec and tar plumbing already exists), list sessions with summary and age, and make
|
||||||
|
**[Resume]** open a terminal running `claude --resume <id>`. Closing a terminal tab today
|
||||||
|
silently abandons a session; it should say "Session saved — resume from Project Home."
|
||||||
|
This turns the biggest architectural quirk into the best feature.
|
||||||
|
|
||||||
|
Do **not** build a checkpoint browser. Mention rewind (`Esc Esc`) in Help and stop there.
|
||||||
|
|
||||||
|
**2. Library — the MCP tab's successor.** The pattern was already invented three times:
|
||||||
|
global MCP servers with per-project checkboxes, global Claude instructions, and Mission
|
||||||
|
Control's bundled skill install. Generalize it once: a Library of **skills, agents, and
|
||||||
|
slash commands** defined globally with per-project enable, synced into the container's
|
||||||
|
`.claude` volume by the entrypoint. Across many projects, "write a skill once, enable it in
|
||||||
|
twelve sandboxes" is genuinely differentiated. Keep the editor minimal — name plus markdown
|
||||||
|
textarea, or "import from folder." Not a structured form per frontmatter field.
|
||||||
|
|
||||||
|
**3. Permission mode as the hero control.** The whole pitch is "sandbox so you can safely
|
||||||
|
go fast," yet that pitch is expressed as a scary boolean buried in a config accordion.
|
||||||
|
Replace it with Claude Code's real vocabulary — a segmented control (**Plan / Default /
|
||||||
|
Accept Edits / Bypass**) on Overview, echoed as a badge on terminal tabs, with sandbox
|
||||||
|
state beside it. When sandbox is ON, Bypass loses its red paint ("contained by sandbox");
|
||||||
|
when sandbox is OFF *and* Bypass is on, that is when caution color earns its place. This
|
||||||
|
reframes the product's core value in the product's own UI.
|
||||||
|
|
||||||
|
**4. Automation tab.** `triple-c-scheduler` ships in every container with
|
||||||
|
add/list/logs/notifications — and its only UI is a CLAUDE.md paragraph telling Claude to
|
||||||
|
run it. Wrap it: task list (name, cron, last run, enabled), toggle/run-now/view-log, and a
|
||||||
|
notification badge on the project row. "Your nightly agent left you a note" is a reason to
|
||||||
|
open the app in the morning. Fleet-of-scheduled-agents management across projects is
|
||||||
|
something the Claude Code TUI does not offer.
|
||||||
|
|
||||||
|
**Explicitly skip:** status line builder, output-styles editor, hook *editors* (surface the
|
||||||
|
count, deep-link to the terminal), checkpoint browser, marketplace browser. Each is niche,
|
||||||
|
natively handled, or a settings-editor trap.
|
||||||
|
|
||||||
|
### B4. Coherence test
|
||||||
|
|
||||||
|
Every screen answers exactly one question:
|
||||||
|
|
||||||
|
| Screen | Question |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar | What projects exist and are they up? |
|
||||||
|
| Project Home | What can this sandbox do, and where did I leave off? |
|
||||||
|
| Terminal | Do the work. |
|
||||||
|
| Library | What capabilities do I reuse? |
|
||||||
|
| Settings | How does the host behave? |
|
||||||
|
|
||||||
|
Anything that doesn't answer one of those doesn't get a nav slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorities
|
||||||
|
|
||||||
|
### Tier 1 — high impact, cheap
|
||||||
|
|
||||||
|
1. `:focus-visible` ring and stop stripping outlines (one CSS rule + token). Add
|
||||||
|
`Ctrl+T` / `Ctrl+W` / `Ctrl+1..9` / `Ctrl+Tab`.
|
||||||
|
2. Contrast: `--accent-emphasis: #1f6feb` for filled buttons; kill white-on-`#3fb950`;
|
||||||
|
`--text-disabled` instead of `opacity-50`.
|
||||||
|
3. Real buttons for project actions; Remove into an overflow menu; primary action filled.
|
||||||
|
4. Inline start/stop progress and an error toast; delete `ContainerProgressModal`.
|
||||||
|
5. Status dots get labels or shapes; Docker-down turns red; null state pulses.
|
||||||
|
6. Welcome screen becomes an onboarding checklist with a real button, plus the logo.
|
||||||
|
7. One shared `<Modal>` with focus trap and ARIA for the modals that remain.
|
||||||
|
8. Permission-mode segmented control replacing the boolean.
|
||||||
|
9. `lucide-react` icons; move the tab strip onto the terminal panel.
|
||||||
|
|
||||||
|
### Tier 2 — high impact, expensive
|
||||||
|
|
||||||
|
1. **Project Home tabbed view** — the structural fix that dissolves the modal pile and the
|
||||||
|
1,257-line ProjectCard. The forms already exist; this is mostly moving and splitting.
|
||||||
|
2. **Sessions tab** with `claude --resume`.
|
||||||
|
3. **Library** — generalize global→per-project sync to skills/agents/commands.
|
||||||
|
4. **Automation tab** wrapping `triple-c-scheduler`, with notification badges.
|
||||||
|
|
||||||
|
### Tier 3 — skip
|
||||||
|
|
||||||
|
- Light theme (dark-only is right; tokens keep the door open).
|
||||||
|
- Editors for hooks, statusline, output styles; checkpoint browser; marketplace browser.
|
||||||
|
- Any new global sidebar tab beyond Library.
|
||||||
|
- Rebuilding MCP management in any form. Let the deletion be a lesson, not a vacancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**One sentence:** promote the project from a sidebar card to a first-class workspace view,
|
||||||
|
use the volume you already own to surface sessions/capabilities/automation instead of
|
||||||
|
building config editors, and spend a focused week on focus rings, contrast, and button
|
||||||
|
affordances — the visual layer needs sanding, not redesign.
|
||||||
@@ -1,6 +1,32 @@
|
|||||||
|
<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 can optionally enable full permissions mode (`--dangerously-skip-permissions`), giving 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
|
||||||
|
|
||||||
@@ -13,46 +39,175 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ TopBar (terminal tabs + Docker/Image status) │
|
│ TopBar (MainTabs strip + Docker/Image status + ?) │
|
||||||
├────────────┬────────────────────────────────────────┤
|
├────────────┬────────────────────────────────────────┤
|
||||||
│ Sidebar │ Main Content (terminal views) │
|
│ Sidebar │ Main Content │
|
||||||
│ (25% w, │ │
|
│ (25% w, │ · Project Home views, or │
|
||||||
│ responsive│ │
|
│ responsive│ · terminal views (xterm.js) │
|
||||||
│ min/max) │ │
|
│ min/max) │ │
|
||||||
├────────────┴────────────────────────────────────────┤
|
├────────────┴────────────────────────────────────────┤
|
||||||
│ StatusBar (project/terminal counts) │
|
│ StatusBar (project/terminal counts, STT, scroll) │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The main area is driven by **one ordered tab strip** (`components/layout/MainTabs.tsx`) holding
|
||||||
|
two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a terminal). There is no
|
||||||
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
|
thing is current at a time.
|
||||||
|
|
||||||
|
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||||
|
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||||
|
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||||
|
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||||
|
carries dropped *file paths*.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+T` | New Claude terminal for the current project (no-op unless it is running) |
|
||||||
|
| `Ctrl+Shift+W` | Close the active tab |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
|
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||||
|
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||||
|
|
||||||
|
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||||
|
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||||
|
why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||||
|
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||||
|
|
||||||
|
### Project Home
|
||||||
|
|
||||||
|
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||||
|
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||||
|
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||||
|
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
|
| Tab | Contents |
|
||||||
|
|---|---|
|
||||||
|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||||
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
|
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||||
|
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||||
|
| **Files** | Browse, 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
|
||||||
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
|
progress modal.
|
||||||
|
|
||||||
|
## Permission Modes
|
||||||
|
|
||||||
|
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||||
|
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||||
|
|
||||||
|
| Mode | Serialized | CLI args passed to `claude` |
|
||||||
|
|---|---|---|
|
||||||
|
| **Plan** | `plan` | `--permission-mode plan` |
|
||||||
|
| **Default** | `default` | *(none)* |
|
||||||
|
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
|
||||||
|
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
|
||||||
|
|
||||||
|
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
|
||||||
|
the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing
|
||||||
|
the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it
|
||||||
|
was launched with.
|
||||||
|
|
||||||
|
Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via
|
||||||
|
`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint
|
||||||
|
snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it
|
||||||
|
back into flags for its headless `claude -p` run. Because it travels as container env, a mode change
|
||||||
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
|
forces that).
|
||||||
|
|
||||||
|
## Containers
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings
|
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 (or bash shell) with a PTY
|
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 (filesystem persists in named volume); MCP containers stopped
|
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||||
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
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 removed and recreated from scratch (named volume preserved)
|
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||||
|
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
|
### Mounts
|
||||||
|
|
||||||
| Target in Container | Source | Type | Notes |
|
| Target in Container | Source | Type | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/workspace` | Project directory | Bind | Read-write |
|
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
| `/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-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
|
| `/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 |
|
||||||
|
|
||||||
### Authentication Modes
|
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||||
|
removal, and by nothing else.
|
||||||
|
|
||||||
Each project can independently use one of:
|
### Corporate CA Certificates
|
||||||
|
|
||||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||||
- **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.
|
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||||
- **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.
|
every start — so it survives recreation, migration and Reset.
|
||||||
|
|
||||||
> **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.
|
- **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)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
@@ -60,26 +215,223 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### MCP Server Architecture
|
### Docker Socket Path
|
||||||
|
|
||||||
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
| Mode | Where It Runs | How It Communicates |
|
## Models and Authentication
|
||||||
|------|--------------|---------------------|
|
|
||||||
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
|
|
||||||
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
|
|
||||||
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
|
|
||||||
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
|
|
||||||
|
|
||||||
**Key behaviors**:
|
### Authentication Modes
|
||||||
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
|
|
||||||
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
|
Each project can independently use one of:
|
||||||
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
|
|
||||||
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
|
- **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.
|
||||||
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
|
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||||
|
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||||
|
|
||||||
|
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||||
|
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||||
|
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||||
|
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||||
|
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||||
|
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||||
|
> they get first-class backends of their own rather than going through a translation layer.
|
||||||
|
|
||||||
|
#### Model alias variables
|
||||||
|
|
||||||
|
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||||
|
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||||
|
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||||
|
|
||||||
|
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||||
|
Triple-C therefore sets all four:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||||
|
|
||||||
|
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||||
|
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||||
|
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||||
|
|
||||||
|
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||||
|
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||||
|
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||||
|
|
||||||
|
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
|
|
||||||
|
### Model Gateway (LiteLLM sibling container)
|
||||||
|
|
||||||
|
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||||
|
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||||
|
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||||
|
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||||
|
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||||
|
lifecycle, including auto-start with the app.
|
||||||
|
|
||||||
|
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||||
|
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||||
|
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||||
|
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||||
|
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||||
|
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||||
|
**always** set, because LiteLLM without one accepts any key.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||||
|
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||||
|
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||||
|
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||||
|
anything resembling an `sk-ant-` secret.
|
||||||
|
|
||||||
|
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||||
|
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||||
|
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||||
|
|
||||||
|
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||||
|
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||||
|
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||||
|
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||||
|
it cleared.
|
||||||
|
|
||||||
|
## Bridges to the Host
|
||||||
|
|
||||||
|
### URL Relay (host browser)
|
||||||
|
|
||||||
|
There is no browser and no display inside the container, so any CLI that wants to open a web page
|
||||||
|
— `gh auth login`, `aws sso login`, `gcloud auth login`, `az login`, vendor CLIs, `xdg-open`,
|
||||||
|
Python's `webbrowser` — simply fails. The URL relay forwards the *request* to the host, where the
|
||||||
|
user's real browser is. Nothing is rendered or forwarded from the container; only the URL travels.
|
||||||
|
It complements the Auth Bridge below: the relay gets the login page open, the bridge lets the
|
||||||
|
callback land.
|
||||||
|
|
||||||
|
**Transport — an OSC escape sequence, following `osc52-clipboard`.** `container/triple-c-open`
|
||||||
|
writes
|
||||||
|
|
||||||
|
```
|
||||||
|
ESC ] 7777 ; open ; <base64(url)> BEL
|
||||||
|
```
|
||||||
|
|
||||||
|
to **`/dev/tty`**, and `TerminalView.tsx` picks it up with `term.parser.registerOscHandler(7777, …)`.
|
||||||
|
`/dev/tty` rather than stdout is the whole point: the shim usually runs as a grandchild of
|
||||||
|
something that captures its children's output (Claude Code invoking `gh auth login` as a tool
|
||||||
|
call), so a printed sentinel line — the `###TRIPLE_C_SSO_REFRESH###` approach — would be swallowed
|
||||||
|
by the intermediate process and never reach the terminal. A control sequence on the controlling
|
||||||
|
terminal always arrives, and is invisible to terminals that don't know it. Base64 keeps a `;`,
|
||||||
|
`BEL` or `ESC` inside the URL from breaking out of the sequence.
|
||||||
|
|
||||||
|
**Container side** — `container/triple-c-open`, installed as `xdg-open`, `sensible-browser`,
|
||||||
|
`www-browser`, `x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open`, and exported as
|
||||||
|
`$BROWSER`. Ubuntu 24.04 ships a real `/usr/bin/sensible-browser` (from `sensible-utils`), so that
|
||||||
|
one is `dpkg-divert`ed rather than merely shadowed by a `/usr/local/bin` symlink; `www-browser` and
|
||||||
|
`x-www-browser` are registered through `update-alternatives` and pinned with `--set`, because
|
||||||
|
`sensible-browser` probes them by absolute path and because a later `apt install firefox` must not
|
||||||
|
be able to steal them. `xdg-open` is diverted pre-emptively so installing `xdg-utils` inside the
|
||||||
|
container cannot displace the relay. `BROWSER` is an image-level `ENV` — terminal sessions are
|
||||||
|
separate `docker exec`s and never see what the entrypoint exported — and the entrypoint also
|
||||||
|
forwards it into the scheduler's cron environment file.
|
||||||
|
|
||||||
|
**No terminal attached** (cron-driven scheduled tasks, or a plain `docker exec` from outside
|
||||||
|
Triple-C): there is no handshake and nothing to wait for, so the shim never blocks. The write to
|
||||||
|
`/dev/tty` fails, and it prints the URL in plain text on its own line and exits 0 — which lands in
|
||||||
|
the scheduler task log where a human can still act on it.
|
||||||
|
|
||||||
|
**Security posture — the container is the untrusted side.** `app/src/lib/urlRelay.ts` validates
|
||||||
|
before anything reaches `openUrl`: `http:`/`https:` only (`file:`, `javascript:`, `data:` and every
|
||||||
|
registered protocol handler rejected), no embedded credentials, no control characters or
|
||||||
|
whitespace, length-capped, and returned WHATWG-normalized so the prompt shows exactly what will
|
||||||
|
open. Nothing opens automatically — the user confirms in the existing `UrlToast`, and prompts are
|
||||||
|
rate-limited (5 per 10 s, repeats of the same URL collapsed) so a loop in the container cannot bury
|
||||||
|
the UI.
|
||||||
|
|
||||||
|
**Web terminal** — deliberately *not* a copy of the desktop behaviour. The browser there belongs to
|
||||||
|
a remote viewer, possibly on a phone across a tunnel, so `terminal.html` renders the relayed URL as
|
||||||
|
a tap-to-open link banner with the same scheme allowlist and rate limit, and opens nothing by
|
||||||
|
itself. The OSC handler is registered regardless so the sequence is consumed rather than painted as
|
||||||
|
garbage.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
|
||||||
|
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
|
||||||
|
browser's redirect to reach it. `auth_bridge/` closes that gap:
|
||||||
|
|
||||||
|
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
|
||||||
|
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
|
||||||
|
binds are deliberately ignored (that is the port-mappings feature's job).
|
||||||
|
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
|
||||||
|
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
|
||||||
|
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
|
||||||
|
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
|
||||||
|
IPs are not routable from the host on Docker Desktop.
|
||||||
|
- Ports already covered by the project's port mappings are skipped, and a host port that is already
|
||||||
|
in use is reported as a conflict rather than fought over.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
|
||||||
|
recreates the container. The poller stops on its own when the container stops.
|
||||||
|
|
||||||
|
**Security posture:** the host side binds loopback only. Everything reachable through it is an
|
||||||
|
unauthenticated service inside the container, so widening those addresses would publish container
|
||||||
|
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||||
|
|
||||||
|
### Browser View
|
||||||
|
|
||||||
|
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||||
|
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||||
|
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||||
|
per project.
|
||||||
|
|
||||||
|
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||||
|
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||||
|
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||||
|
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||||
|
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||||
|
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||||
|
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||||
|
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||||
|
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||||
|
iframe while popped out, and both viewers can drive the browser.
|
||||||
|
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||||
|
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||||
|
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||||
|
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||||
|
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||||
|
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||||
|
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||||
|
continuously and each event costs a container exec.
|
||||||
|
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||||
|
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||||
|
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||||
|
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||||
|
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||||
|
recreation *and* migration.
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||||
|
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||||
|
once Playwright is present — not a setup route.
|
||||||
|
|
||||||
|
## Inside a Project
|
||||||
|
|
||||||
|
### Container Introspection (Capability Tiles)
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
|
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||||
|
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||||
|
|
||||||
|
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||||
|
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||||
|
|
||||||
### Mission Control Integration
|
### Mission Control Integration
|
||||||
|
|
||||||
@@ -99,75 +451,122 @@ The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stor
|
|||||||
|
|
||||||
### Speech-to-Text (Voice Mode)
|
### Speech-to-Text (Voice Mode)
|
||||||
|
|
||||||
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the bottom-left corner of each terminal view.
|
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active.
|
||||||
|
|
||||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
- **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) |
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||||
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||||
| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) |
|
| `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/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||||
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings |
|
|
||||||
|
### Frontend — Project Home
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||||
|
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||||
|
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||||
|
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||||
|
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||||
|
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||||
|
| `app/src/components/projects/home/FilesTab.tsx` | 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/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||||
|
|
||||||
|
### Frontend — settings, terminal and hooks
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||||
|
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||||
|
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||||
|
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
| `app/src/components/settings/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/SttButton.tsx` | Mic button overlay with on-demand container start |
|
| `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/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
| `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/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/useMcpServers.ts` | MCP server CRUD operations |
|
| `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-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
|
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
|
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||||
|
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
|
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||||
|
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||||
|
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||||
|
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||||
|
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||||
|
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||||
|
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||||
|
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||||
|
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||||
|
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||||
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, MCP servers, Mission Control) |
|
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||||
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
|
| `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, Claude Code settings, web terminal, STT) |
|
| `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/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
### Container and packaging
|
||||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
|
||||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||||
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||||
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
|
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||||
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
|
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||||
|
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||||
|
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||||
|
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
| `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 |
|
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup |
|
|
||||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
|
||||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
|
||||||
|
|
||||||
## CSS / Styling Notes
|
## CSS / Styling Notes
|
||||||
|
|
||||||
@@ -180,8 +579,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)
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Triple-C Roadmap — Claude Code Feature Parity
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226
|
||||||
|
|
||||||
|
Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and
|
||||||
|
information architecture. This document covers *which Claude Code capabilities Triple-C
|
||||||
|
should surface, and why.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding principle
|
||||||
|
|
||||||
|
> **Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Triple-C's built-in MCP server management was removed in this cycle because Claude Code
|
||||||
|
absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`).
|
||||||
|
Hooks, skills, agents, plugins, output styles, and statusline are the same species: files
|
||||||
|
under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them
|
||||||
|
means losing the same race again.
|
||||||
|
|
||||||
|
What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled
|
||||||
|
scheduler, and the fleet view across many projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current coverage (v0.3.0)
|
||||||
|
|
||||||
|
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
|
||||||
|
|
||||||
|
| Key | Surfaced as |
|
||||||
|
|---|---|
|
||||||
|
| `tui` | TUI Mode select (`fullscreen`) |
|
||||||
|
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
||||||
|
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
||||||
|
| `focusMode` | Focus Mode toggle |
|
||||||
|
| `showThinkingSummaries` | Thinking Summaries toggle |
|
||||||
|
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||||
|
|
||||||
|
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||||
|
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||||
|
`CLAUDE_CODE_*` vars via the Env Vars modal.
|
||||||
|
|
||||||
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
|
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||||
|
file drag-drop, STT), the web terminal, and workspace backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap analysis
|
||||||
|
|
||||||
|
### Committed for this cycle
|
||||||
|
|
||||||
|
| # | Gap | Today | Plan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. |
|
||||||
|
| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume <id>`. |
|
||||||
|
| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. |
|
||||||
|
| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. |
|
||||||
|
| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. |
|
||||||
|
|
||||||
|
### Deliberately skipped
|
||||||
|
|
||||||
|
Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser ·
|
||||||
|
plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a
|
||||||
|
settings-editor trap. Surface counts and deep-link instead.
|
||||||
|
|
||||||
|
### Not yet scheduled
|
||||||
|
|
||||||
|
- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories`
|
||||||
|
- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`,
|
||||||
|
`excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS`
|
||||||
|
- Project-level `.claude/settings.json` vs user-level settings hierarchy
|
||||||
|
- A model picker. **Note:** the only model strings in the app today are stale placeholders
|
||||||
|
(`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`,
|
||||||
|
`qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not
|
||||||
|
dropdowns, but they should be refreshed to current model identifiers regardless.
|
||||||
|
- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a
|
||||||
|
user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale
|
||||||
|
on every container start. Worth deepening to `*` recursive merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication handoff
|
||||||
|
|
||||||
|
**Goal:** stop making users hand-copy an auth code into every container.
|
||||||
|
|
||||||
|
**Constraint discovered during research:** `claude login`'s callback server uses an
|
||||||
|
**ephemeral port** and its redirect URI is **not configurable** for the main login flow
|
||||||
|
(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design
|
||||||
|
that pre-assigns each container a fixed callback port and routes to it cannot work as
|
||||||
|
stated — there is no fixed port to route.
|
||||||
|
|
||||||
|
There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first,
|
||||||
|
so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
|
||||||
|
Two viable options:
|
||||||
|
|
||||||
|
### Option A — long-lived token injection (simple)
|
||||||
|
|
||||||
|
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
|
||||||
|
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
|
||||||
|
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
|
||||||
|
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
|
||||||
|
|
||||||
|
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
|
||||||
|
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
|
||||||
|
under a pty: its `redirect_uri` is Anthropic-hosted
|
||||||
|
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
|
||||||
|
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
|
||||||
|
is therefore mandatory — the flow cannot complete without one.
|
||||||
|
|
||||||
|
- No routing, no ports, no proxy.
|
||||||
|
- One auth event covers every project.
|
||||||
|
- Cost: small. Reuses existing keychain and env-injection plumbing.
|
||||||
|
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
|
||||||
|
token cannot drive Remote Control sessions or claude.ai connector fetches.
|
||||||
|
|
||||||
|
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
|
||||||
|
label, not a hash of the token. Labels are readable by anything that can run
|
||||||
|
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
|
||||||
|
token you could confirm it. A presence boolean would instead miss rotations and silently
|
||||||
|
leave containers on a stale token.
|
||||||
|
|
||||||
|
### Option B — the Auth Bridge (general loopback-callback bridge)
|
||||||
|
|
||||||
|
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
|
||||||
|
starting a temporary loopback listener and opening a browser at a URL that redirects back
|
||||||
|
to it — Concourse `fly login` (random loopback port serving `/auth/callback`),
|
||||||
|
`aws sso login`, and many others. Inside a container the host browser cannot reach that
|
||||||
|
listener, so login stalls.
|
||||||
|
|
||||||
|
Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge
|
||||||
|
**discovers** listeners instead:
|
||||||
|
|
||||||
|
1. While enabled for a running project, poll the container for loopback TCP listeners by
|
||||||
|
reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on
|
||||||
|
`ss`/`netstat`/`lsof`, which aren't guaranteed in the image.
|
||||||
|
2. For each newly-appeared loopback listener, bind **the same port on the host's
|
||||||
|
`127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN).
|
||||||
|
3. Proxy each accepted connection into the container over the Docker API via
|
||||||
|
`socat - TCP:127.0.0.1:<port>` (socat already ships in the image), reusing the existing
|
||||||
|
attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a
|
||||||
|
container IP keeps this working on Docker Desktop, where container IPs are not routable
|
||||||
|
from the host.
|
||||||
|
4. Fall back to `TCP6:[::1]:<port>` when the listener appeared only on IPv6 — on Linux,
|
||||||
|
Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or
|
||||||
|
the app exits. Ports already covered by the project's explicit port mappings are skipped;
|
||||||
|
host-side conflicts are reported rather than silently swallowed.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal
|
||||||
|
loopback services reachable from the host.
|
||||||
|
|
||||||
|
**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case
|
||||||
|
at a fraction of the cost — and **B** as the general mechanism covering every other CLI.
|
||||||
|
They compose: A means most users never trigger a browser login at all; B catches AWS SSO,
|
||||||
|
Concourse, and anything else that needs a real callback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing
|
||||||
|
migration for containers created against the old per-project Docker network.
|
||||||
|
|
||||||
|
**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix
|
||||||
|
below). Read-only introspection backend: sessions, capabilities, scheduler.
|
||||||
|
|
||||||
|
**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop
|
||||||
|
progress, status labels, onboarding welcome screen, shared accessible `<Modal>`.
|
||||||
|
|
||||||
|
**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed
|
||||||
|
main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and
|
||||||
|
splitting the 1,257-line `ProjectCard`.
|
||||||
|
|
||||||
|
**Phase 4 — authentication handoff.** Option A, then evaluate B.
|
||||||
|
|
||||||
|
**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into
|
||||||
|
the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs found during this review
|
||||||
|
|
||||||
|
1. **Scheduled tasks ignore the project's permission setting.**
|
||||||
|
`container/triple-c-task-runner:69` runs
|
||||||
|
`claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the
|
||||||
|
project's Full Permissions toggle. Being fixed as part of Phase 1.
|
||||||
|
|
||||||
|
2. **Docs claim Reset preserves credentials; it does not.**
|
||||||
|
`rebuild_project_container` calls `remove_project_volumes`, which deletes both
|
||||||
|
`triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}`
|
||||||
|
(holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that
|
||||||
|
OAuth tokens survive a Reset. Pre-existing; not yet corrected.
|
||||||
|
|
||||||
|
3. **An invalid cron expression silently unscheduled every task.** Found while adding
|
||||||
|
task creation to the Automation tab, and the most serious bug in this review.
|
||||||
|
`triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the
|
||||||
|
*entire* crontab and pipes it to `crontab`, which rejects the whole file if any single
|
||||||
|
line is malformed — with the error discarded by `2>/dev/null || true`. So one bad
|
||||||
|
schedule silently unscheduled every other task in the container, reporting success.
|
||||||
|
Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use
|
||||||
|
this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the
|
||||||
|
expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead
|
||||||
|
of swallowing it. The Rust `add_scheduled_task` command validates independently.
|
||||||
|
|
||||||
|
4. **Reset was destructive with no confirmation.** It deletes both volumes — the login,
|
||||||
|
installed skills, all session transcripts — from a single unconfirmed click, while the
|
||||||
|
comparably destructive Remove already confirmed. Now gated by a dialog that names each
|
||||||
|
loss. Fixed.
|
||||||
|
|
||||||
|
5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above.
|
||||||
|
|
||||||
|
6. **Stale model placeholders** — see "Not yet scheduled" above.
|
||||||
|
|
||||||
|
7. **Silent save failures.** Project config saves on blur; failures went only to
|
||||||
|
`console.error`. Fixed in Phase 3 — `useProjectSave` now renders a
|
||||||
|
Saved / Saving / Save failed indicator and raises a toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps left by Phase 2–3
|
||||||
|
|
||||||
|
- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit`
|
||||||
|
subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is
|
||||||
|
implemented as add-then-remove. The add runs first, so a rejected edit leaves the original
|
||||||
|
intact. The task gets a new id and its older logs stay under the old one; the editor says
|
||||||
|
so before saving.
|
||||||
|
- **`open_terminal_session` takes no command argument.** "Resume session" and
|
||||||
|
"Manage in terminal" therefore open a bash tab and *type* the command after a
|
||||||
|
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
|
||||||
|
slow container start. The fix is a `command: Option<String>` parameter on the
|
||||||
|
Tauri command so the exec launches the process directly.
|
||||||
|
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
|
||||||
|
with no start time, so Project Home records "running since" when the app *sees*
|
||||||
|
the transition. A container already running when the app launches shows
|
||||||
|
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
|
||||||
|
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
|
||||||
|
access in the build environment used for this cycle. The existing inline SVGs and
|
||||||
|
text glyphs remain.
|
||||||
|
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
|
||||||
|
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
|
||||||
|
the tabs in the TopBar; the diagram won. Worth revisiting.
|
||||||
|
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
|
||||||
|
`kill-word`, used constantly inside the terminal this app is built around;
|
||||||
|
intercepting it globally would break word-erase in every shell.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with `--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode — `--dangerously-skip-permissions` — Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,6 +57,16 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
||||||
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
||||||
|
|
||||||
|
#### Terminal Layout & StatusBar Controls
|
||||||
|
|
||||||
|
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||||
|
|
||||||
|
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||||
|
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||||
|
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||||
|
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||||
|
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||||
|
|
||||||
### bollard (Docker API)
|
### bollard (Docker API)
|
||||||
|
|
||||||
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
||||||
@@ -113,7 +123,8 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
┌──────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ Docker Container (per project) │
|
│ Docker Container (per project) │
|
||||||
│ │
|
│ │
|
||||||
│ /workspace ←── bind mount ──► Host project directory │
|
│ /workspace/<name> ←─ bind mount ─► Host project folder │
|
||||||
|
│ /home/claude ←── named volume (home dir) │
|
||||||
│ /home/claude/.claude ←── named volume (persists config) │
|
│ /home/claude/.claude ←── named volume (persists config) │
|
||||||
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
||||||
│ /var/run/docker.sock ←── optional (sibling containers) │
|
│ /var/run/docker.sock ←── optional (sibling containers) │
|
||||||
@@ -150,22 +161,178 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
|
|||||||
|
|
||||||
Containers follow a **stop/start** model, not create/destroy:
|
Containers follow a **stop/start** model, not create/destroy:
|
||||||
|
|
||||||
1. **First start**: A new container is created with bind mounts, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive.
|
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
|
||||||
2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
|
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
|
||||||
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
||||||
4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist.
|
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
|
||||||
5. **Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted.
|
5. **Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image.
|
||||||
|
|
||||||
The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets.
|
Two named volumes exist per project and they are the only ones it owns:
|
||||||
|
|
||||||
|
| Volume | Mount point | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `triple-c-home-{projectId}` | `/home/claude` | Home directory — `~/.claude.json`, `~/.local`, `~/.ssh`, `~/.aws` |
|
||||||
|
| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. |
|
||||||
|
|
||||||
|
`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from
|
||||||
|
exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal
|
||||||
|
passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project
|
||||||
|
removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills,
|
||||||
|
session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or
|
||||||
|
host keychain secrets.
|
||||||
|
|
||||||
|
### Permission Modes
|
||||||
|
|
||||||
|
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
|
||||||
|
boolean. It reaches Claude Code by two different routes:
|
||||||
|
|
||||||
|
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
|
||||||
|
|---|---|---|
|
||||||
|
| `Plan` | `--permission-mode plan` | `plan` |
|
||||||
|
| `Default` | *(no flag)* | `default` |
|
||||||
|
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
|
||||||
|
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
|
||||||
|
|
||||||
|
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
|
||||||
|
`None` from the legacy `full_permissions` flag, so records written before the change keep behaving
|
||||||
|
the same way.
|
||||||
|
|
||||||
|
**Interactive path.** `build_terminal_cmd()` evaluates `cli_args()` when a session is created, so
|
||||||
|
the flags are fixed for the life of that `claude` process. Changing the mode affects terminals
|
||||||
|
opened afterwards, not running ones. The same applies to `resume_session_command`, which builds
|
||||||
|
`claude <flags> --resume <id>` server-side.
|
||||||
|
|
||||||
|
**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as
|
||||||
|
`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted
|
||||||
|
variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the
|
||||||
|
value back to flags for its `claude -p` run. Container env can only change at create time, so
|
||||||
|
`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation
|
||||||
|
on the next start. A mode change therefore reaches new terminals immediately but the scheduler only
|
||||||
|
after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set.
|
||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project independently chooses one of two authentication methods:
|
Each project independently chooses one backend:
|
||||||
|
|
||||||
| Mode | How It Works | When to Use |
|
| Backend | How It Works | When to Use |
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | 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, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | 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 the placeholder `ollama`. Ollama implements `POST /v1/messages` natively. | Local models (best-effort) |
|
||||||
|
| **llama.cpp** | `ANTHROPIC_BASE_URL` points at a `llama-server` (default port 8080); `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `llama.cpp`, which `llama-server` ignores unless started with `--api-key`. `llama-server` implements `POST /v1/messages` and `/v1/messages/count_tokens` natively. | Local models (best-effort) |
|
||||||
|
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at a gateway. **Despite the name, the endpoint must implement the Anthropic Messages API** — Claude Code only ever sends `POST /v1/messages?beta=true`, never `/v1/chat/completions`. LiteLLM works; a bare OpenAI-only server does not. | Anthropic-shaped gateways (best-effort) |
|
||||||
|
|
||||||
|
#### Model aliases on custom endpoints
|
||||||
|
|
||||||
|
`Backend::uses_custom_endpoint()` (Ollama, llama.cpp, OpenAI Compatible) gates the emission of
|
||||||
|
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL`, computed by
|
||||||
|
`docker::container::compute_model_aliases`. All four default to the backend's resolved model id;
|
||||||
|
each backend carries an optional `haiku_model_id` override, because the Haiku alias is what Claude
|
||||||
|
Code uses for background work. Anthropic and Bedrock emit none of them and keep Claude Code's
|
||||||
|
defaults; the four names are in `MANAGED_AUTH_KEYS`, so switching away from a custom endpoint
|
||||||
|
blanks the values baked into the snapshot image. The resolved alias set is folded into each
|
||||||
|
backend's `triple-c.*-fingerprint` label, since `container_needs_recreation` is label-based and
|
||||||
|
never diffs env. `ANTHROPIC_SMALL_FAST_MODEL` is deprecated and unused.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container.
|
||||||
|
Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted
|
||||||
|
page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back
|
||||||
|
in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two
|
||||||
|
concurrent logins would race to overwrite each other's keychain entry) and times out after 15
|
||||||
|
minutes.
|
||||||
|
|
||||||
|
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||||
|
frontend, never written to a log, and no command accepts or returns it.
|
||||||
|
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||||
|
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
|
||||||
|
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
|
||||||
|
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
|
||||||
|
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||||
|
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||||
|
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||||
|
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||||
|
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||||
|
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||||
|
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||||
|
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||||
|
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
|
||||||
|
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
|
||||||
|
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
|
||||||
|
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||||
|
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||||
|
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||||
|
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||||
|
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||||
|
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||||
|
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||||
|
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||||
|
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||||
|
token onto the next frame's first word.
|
||||||
|
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||||
|
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||||
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
|
value baked into a snapshot image by `docker commit` is actively cleared.
|
||||||
|
- **Rotation** — a random UUID minted on each store is mirrored into the
|
||||||
|
`triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are
|
||||||
|
readable by anything that can run `docker inspect`, and a hash would be an offline verification
|
||||||
|
oracle. A label mismatch forces container recreation on the next start, which is when a container
|
||||||
|
picks up or loses the token.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral
|
||||||
|
HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:<port>/…`
|
||||||
|
redirect. Run inside a container, that listener is unreachable from the host browser and nothing can
|
||||||
|
be pre-published at container-creation time. `auth_bridge/` bridges it at runtime:
|
||||||
|
|
||||||
|
- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every
|
||||||
|
two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`)
|
||||||
|
bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the
|
||||||
|
port-mappings feature's job.
|
||||||
|
- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node
|
||||||
|
resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The
|
||||||
|
socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are
|
||||||
|
treated as IPv4.
|
||||||
|
- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required,
|
||||||
|
`[::1]` is best-effort. **The host side binds loopback only, never a wildcard address** —
|
||||||
|
everything behind it is an unauthenticated in-container service that bound loopback precisely
|
||||||
|
because it expected to be unreachable.
|
||||||
|
- **Transport** — each accepted connection is proxied by an attached exec running
|
||||||
|
`socat - TCP:127.0.0.1:<port>`, because container IPs are not routable from the host under Docker
|
||||||
|
Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with
|
||||||
|
`tty: false` so socat's stderr is demultiplexed away from the proxied byte stream.
|
||||||
|
- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure
|
||||||
|
is recorded as a conflict and retried later rather than fought over.
|
||||||
|
- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely
|
||||||
|
host-side, so it deliberately has no container-recreation label. The poller stops itself when the
|
||||||
|
project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits
|
||||||
|
it so host ports are provably released.
|
||||||
|
|
||||||
|
### Container Introspection
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in
|
||||||
|
a running container and returns counts and item lists for skills, agents, commands, hooks, plugins
|
||||||
|
and MCP servers, across user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with
|
||||||
|
`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros
|
||||||
|
rather than an error.
|
||||||
|
|
||||||
|
The script writes nothing. Claude Code owns this configuration and has its own tooling for it
|
||||||
|
(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than
|
||||||
|
rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands
|
||||||
|
(`list_scheduled_tasks`, `get_scheduled_task_log`, `set_scheduled_task_enabled`,
|
||||||
|
`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same
|
||||||
|
module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files.
|
||||||
|
|
||||||
|
### Main-Area Tab Model
|
||||||
|
|
||||||
|
The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds,
|
||||||
|
`home:<projectId>` and `term:<sessionId>`, rendered by `components/layout/MainTabs.tsx`.
|
||||||
|
`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project
|
||||||
|
Home tab and a terminal cannot both claim focus. Project configuration is a main-area view
|
||||||
|
(`components/projects/home/`), not a modal; the sidebar row is select-only.
|
||||||
|
|
||||||
### UID/GID Remapping
|
### UID/GID Remapping
|
||||||
|
|
||||||
@@ -195,10 +362,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
|
|||||||
| Data | Storage | Location |
|
| Data | Storage | Location |
|
||||||
|------|---------|----------|
|
|------|---------|----------|
|
||||||
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
||||||
| API keys | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
| API keys and per-project secrets | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
||||||
|
| Shared Claude token + rotation id | OS keychain | Separate service entries; never on disk, never in a label |
|
||||||
| App settings | Tauri plugin-store | App data directory |
|
| App settings | Tauri plugin-store | App data directory |
|
||||||
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
||||||
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
|
| Container home directory | Named Docker volume | `triple-c-home-{projectId}` |
|
||||||
|
| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset |
|
||||||
|
|
||||||
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
||||||
|
|
||||||
@@ -220,98 +389,160 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
|||||||
triple-c/
|
triple-c/
|
||||||
├── README.md # Architecture overview
|
├── README.md # Architecture overview
|
||||||
├── TECHNICAL.md # This document
|
├── TECHNICAL.md # This document
|
||||||
├── HOW-TO-USE.md # User guide
|
├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog)
|
||||||
├── BUILDING.md # Build instructions
|
├── BUILDING.md # Build instructions
|
||||||
├── CLAUDE.md # Claude Code instructions
|
├── CLAUDE.md # Claude Code instructions
|
||||||
|
├── DESIGN-REVIEW.md # UI/UX review notes
|
||||||
|
├── ROADMAP.md # Planned work
|
||||||
│
|
│
|
||||||
├── container/
|
├── container/ # Sandbox image
|
||||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||||
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
|
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection,
|
||||||
|
│ │ # scheduler env snapshot + crontab rebuild
|
||||||
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
||||||
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
||||||
│ ├── triple-c-scheduler # Bash-based cron task system
|
│ ├── triple-c-scheduler # Bash-based cron task system
|
||||||
│ └── triple-c-task-runner # Task execution runner for scheduler
|
│ ├── triple-c-task-runner # Cron entry point; permission mode → flags → `claude -p`
|
||||||
|
│ ├── triple-c-sso-refresh # AWS SSO session refresh helper
|
||||||
|
│ └── mission-control/ # Bundled Flight Control methodology (skills, docs, templates)
|
||||||
|
│
|
||||||
|
├── stt-container/ # Speech-to-text image
|
||||||
|
│ ├── Dockerfile # Faster Whisper (Python 3.11 + FastAPI)
|
||||||
|
│ └── server.py # POST /transcribe endpoint
|
||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||||
|
│ ├── build-app-preview.yml # Preview builds
|
||||||
│ ├── build.yml # Build container image (multi-arch)
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||||
│ └── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
|
│ └── cleanup-releases.yml # Prune old releases
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
├── vite.config.ts # Vite bundler config
|
├── vite.config.ts # Vite bundler config
|
||||||
|
├── vitest.config.ts # Vitest (jsdom) config
|
||||||
├── index.html # HTML entry point
|
├── index.html # HTML entry point
|
||||||
│
|
│
|
||||||
├── src/ # React frontend
|
├── src/ # React frontend
|
||||||
│ ├── main.tsx # React DOM root
|
│ ├── main.tsx # React DOM root
|
||||||
│ ├── App.tsx # Top-level layout
|
│ ├── App.tsx # Top-level layout + welcome screen
|
||||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
│ ├── index.css # CSS variables, dark theme, focus ring, scrollbars
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
|
│ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts)
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
|
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||||
|
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
│ │ ├── useFileManager.ts # File manager operations
|
│ │ ├── useFileManager.ts # File browser operations
|
||||||
│ │ ├── useMcpServers.ts # MCP server CRUD
|
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||||
|
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||||
|
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||||
│ │ ├── useProjects.ts # Project CRUD operations
|
│ │ ├── useProjects.ts # Project CRUD operations
|
||||||
|
│ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state
|
||||||
│ │ ├── useSettings.ts # App settings
|
│ │ ├── useSettings.ts # App settings
|
||||||
|
│ │ ├── useSTT.ts # Speech-to-text recording and container control
|
||||||
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
||||||
│ │ ├── useUpdates.ts # App update checking
|
│ │ ├── useUpdates.ts # App update checking
|
||||||
│ │ └── useVoice.ts # Voice mode audio capture
|
│ │ └── useVoice.ts # Voice mode audio capture
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||||
|
│ │ ├── urlDetector.ts # Long-URL reassembly for OAuth flows
|
||||||
|
│ │ ├── wav.ts # WAV encoding for STT
|
||||||
│ │ └── constants.ts # App-wide constants
|
│ │ └── constants.ts # App-wide constants
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
│ ├── DockerInstallDialog.tsx # First-run Docker setup
|
||||||
│ ├── mcp/ # McpPanel, McpServerCard
|
│ ├── layout/ # TopBar, MainTabs (the unified tab strip),
|
||||||
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
│ │ # Sidebar, StatusBar, HelpDialog
|
||||||
│ │ # FileManagerModal, ContainerProgressModal, modals
|
│ ├── projects/
|
||||||
|
│ │ ├── home/ # Project Home — the main-area project view
|
||||||
|
│ │ │ ├── ProjectHome.tsx # Header, actions, overflow menu, tab strip
|
||||||
|
│ │ │ ├── OverviewTab.tsx # Permission mode, summary, recent activity
|
||||||
|
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||||
|
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||||
|
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||||
|
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||||
|
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||||
|
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||||
|
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||||
|
│ │ │ # AccessSection, RuntimeSection
|
||||||
|
│ │ ├── ProjectRow.tsx # Select-only sidebar row
|
||||||
|
│ │ ├── ProjectList.tsx # Sidebar project list
|
||||||
|
│ │ ├── AddProjectDialog.tsx # New-project dialog
|
||||||
|
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
|
||||||
|
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
|
||||||
|
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
|
||||||
|
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||||
|
│ │ # editors reused by Project Home
|
||||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||||
│ │ # WebTerminalSettings, UpdateDialog
|
│ │ # OllamaSettings, LlamaCppSettings,
|
||||||
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
│ │ # OpenAiCompatibleSettings,
|
||||||
|
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
||||||
|
│ │ # WebTerminalSettings, SttSettings,
|
||||||
|
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
||||||
|
│ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu,
|
||||||
|
│ │ # SttButton, UrlToast, trimSelection
|
||||||
|
│ └── ui/ # Shared primitives: Modal, Button, Toggle, Field,
|
||||||
|
│ # SegmentedControl, StatusIndicator, SaveIndicator,
|
||||||
|
│ # OverflowMenu, ToastHost, Tooltip, AccordionSection
|
||||||
│
|
│
|
||||||
└── src-tauri/ # Rust backend
|
└── src-tauri/ # Rust backend
|
||||||
├── Cargo.toml # Rust dependencies
|
├── Cargo.toml # Rust dependencies
|
||||||
├── tauri.conf.json # Tauri app configuration
|
├── tauri.conf.json # Tauri app configuration
|
||||||
|
├── build.rs # Tauri build script
|
||||||
├── capabilities/
|
├── capabilities/
|
||||||
│ └── default.json # Tauri v2 permission grants
|
│ └── default.json # Tauri v2 plugin permission grants
|
||||||
└── src/
|
└── src/
|
||||||
├── lib.rs # App builder, plugin + command registration
|
├── lib.rs # App builder, plugin + command registration
|
||||||
├── main.rs # Entry point
|
├── main.rs # Entry point
|
||||||
├── logging.rs # Log configuration
|
├── logging.rs # Log configuration
|
||||||
├── commands/ # Tauri command handlers
|
├── commands/ # Tauri command handlers
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge
|
||||||
│ ├── file_commands.rs # File manager (list/download/upload)
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── mcp_commands.rs # MCP server CRUD
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── project_commands.rs # Start/stop/rebuild containers
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
│ ├── settings_commands.rs # Settings CRUD
|
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||||
│ ├── terminal_commands.rs # Terminal I/O, resize
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── update_commands.rs # App update checking
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
│ ├── project_commands.rs # Start/stop/rebuild/backup containers
|
||||||
|
│ ├── settings_commands.rs # Settings CRUD
|
||||||
|
│ ├── stt_commands.rs # STT start/stop/transcribe
|
||||||
|
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||||
|
│ ├── update_commands.rs # App update checking
|
||||||
│ └── web_terminal_commands.rs # Web terminal start/stop/status
|
│ └── web_terminal_commands.rs # Web terminal start/stop/status
|
||||||
├── web_terminal/ # Remote terminal access
|
├── auth_bridge/ # Host-side loopback callback bridge
|
||||||
|
│ ├── mod.rs # Per-project poller, status, lifecycle
|
||||||
|
│ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering
|
||||||
|
│ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API
|
||||||
|
├── web_terminal/ # Remote terminal access
|
||||||
│ ├── mod.rs # Module root
|
│ ├── mod.rs # Module root
|
||||||
│ ├── server.rs # Axum HTTP+WS server lifecycle
|
│ ├── server.rs # Axum HTTP+WS server lifecycle
|
||||||
│ ├── ws_handler.rs # WebSocket connection handler
|
│ ├── ws_handler.rs # WebSocket connection handler
|
||||||
│ └── terminal.html # Embedded xterm.js web UI
|
│ └── terminal.html # Embedded xterm.js web UI
|
||||||
|
├── install_helper/ # Docker installation assistance
|
||||||
|
│ ├── mod.rs # Install orchestration
|
||||||
|
│ └── platform.rs # Per-OS install strategies
|
||||||
├── docker/ # Docker API layer
|
├── docker/ # Docker API layer
|
||||||
│ ├── client.rs # bollard singleton connection
|
│ ├── client.rs # bollard singleton connection
|
||||||
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
│ ├── container.rs # Create/start/stop/remove, labels, recreation checks,
|
||||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
│ │ # remove_project_volumes, snapshot commit
|
||||||
|
│ ├── exec.rs # create_attached_exec() — the single attached-exec path
|
||||||
│ ├── image.rs # Build from Dockerfile, pull from registry
|
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||||
│ └── network.rs # Per-project bridge networks for MCP
|
│ ├── stt.rs # Speech-to-text container lifecycle
|
||||||
|
│ └── legacy_cleanup.rs # Migration shim for the removed MCP feature
|
||||||
├── models/ # Data structures
|
├── models/ # Data structures
|
||||||
│ ├── project.rs # Project, Backend, BedrockConfig
|
│ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, …
|
||||||
│ ├── mcp_server.rs # MCP server configuration
|
│ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal)
|
||||||
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
|
||||||
│ ├── container_config.rs # Image name resolution
|
│ ├── container_config.rs # Image name resolution
|
||||||
│ └── update_info.rs # Update metadata
|
│ └── update_info.rs # Update metadata
|
||||||
└── storage/ # Persistence
|
└── storage/ # Persistence
|
||||||
├── projects_store.rs # JSON file with atomic writes
|
├── projects_store.rs # JSON file with atomic writes
|
||||||
├── mcp_store.rs # MCP server persistence
|
|
||||||
├── settings_store.rs # App settings (Tauri plugin-store)
|
├── settings_store.rs # App settings (Tauri plugin-store)
|
||||||
└── secure.rs # OS keychain via keyring
|
└── secure.rs # OS keychain via keyring (secrets, shared token)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -335,6 +566,11 @@ triple-c/
|
|||||||
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
||||||
| `dirs` | 6.x | Cross-platform app data directory paths |
|
| `dirs` | 6.x | Cross-platform app data directory paths |
|
||||||
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
||||||
|
| `log` / `fern` | 0.4 / 0.7 | Date-based file logging |
|
||||||
|
| `include_dir` | 0.7 | Embeds the container build context in the binary |
|
||||||
|
| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads |
|
||||||
|
| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` |
|
||||||
|
| `sha2` | 0.10 | Settings fingerprints |
|
||||||
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
|
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
|
||||||
| `tower-http` | 0.6 | CORS middleware for web terminal |
|
| `tower-http` | 0.6 | CORS middleware for web terminal |
|
||||||
| `base64` | 0.22 | Terminal data encoding over WebSocket |
|
| `base64` | 0.22 | Terminal data encoding over WebSocket |
|
||||||
@@ -357,6 +593,8 @@ triple-c/
|
|||||||
| `zustand` | 5.x | Lightweight state management |
|
| `zustand` | 5.x | Lightweight state management |
|
||||||
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
||||||
| `vite` | 6.x | Frontend build tool and dev server |
|
| `vite` | 6.x | Frontend build tool and dev server |
|
||||||
|
| `vitest` | 4.x | Test runner (jsdom environment) |
|
||||||
|
| `@testing-library/react` | 16.x | Component tests |
|
||||||
|
|
||||||
### Container Image
|
### Container Image
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,702 @@
|
|||||||
|
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
|
||||||
|
//! container complete against the browser on the *host*.
|
||||||
|
//!
|
||||||
|
//! ## The problem
|
||||||
|
//!
|
||||||
|
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
|
||||||
|
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
|
||||||
|
//! then open a browser at a provider URL whose redirect points back to
|
||||||
|
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
|
||||||
|
//! is on the *container's* loopback, the browser is on the *host's*, and the
|
||||||
|
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
|
||||||
|
//! configurable, so nothing can be pre-published at container creation time.
|
||||||
|
//!
|
||||||
|
//! ## The mechanism
|
||||||
|
//!
|
||||||
|
//! While the bridge is enabled for a running project, poll the container every
|
||||||
|
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
|
||||||
|
//! that appears, bind the *same* port on the host's loopback and proxy each
|
||||||
|
//! accepted connection into the container over `docker exec … socat` (see
|
||||||
|
//! [`tunnel`]). When the in-container listener goes away, drop the host
|
||||||
|
//! listener. The host and container therefore agree on the port number, which is
|
||||||
|
//! the whole trick: the redirect URL the provider was given resolves correctly
|
||||||
|
//! on both sides.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle and teardown
|
||||||
|
//!
|
||||||
|
//! One poller task per project. It is the only thing that owns
|
||||||
|
//! [`PortForward`]s, and it always tears them down on its way out, so every way
|
||||||
|
//! the bridge can end funnels through the same code:
|
||||||
|
//!
|
||||||
|
//! | Trigger | Path |
|
||||||
|
//! |---|---|
|
||||||
|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||||
|
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||||
|
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||||
|
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||||
|
//!
|
||||||
|
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
|
||||||
|
//! released before it returns. As a backstop for any path that skips all of the
|
||||||
|
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
|
||||||
|
//! the accept loop, which drops the socket.
|
||||||
|
|
||||||
|
pub mod proc_net;
|
||||||
|
pub mod tunnel;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
|
use proc_net::PortFamily;
|
||||||
|
use tunnel::PortForward;
|
||||||
|
|
||||||
|
/// How often the container is polled for new/vanished loopback listeners.
|
||||||
|
/// Short enough that a login redirect isn't left waiting, cheap enough to run
|
||||||
|
/// continuously (one `cat` of two procfs files per tick).
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Emitted whenever the bridged-port set (or the conflict set) changes.
|
||||||
|
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
|
||||||
|
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC response models
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A port currently bound on the host loopback and forwarded into the container.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BridgedPort {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
|
pub bridged_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PortConflict {
|
||||||
|
pub port: u16,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AuthBridgeStatus {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub active_ports: Vec<BridgedPort>,
|
||||||
|
pub conflicts: Vec<PortConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeStatus {
|
||||||
|
fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
active_ports: Vec::new(),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manager
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Everything the poller owns for one project. Live ports and conflicts sit
|
||||||
|
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||||
|
/// disturbing the poller.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BridgeState {
|
||||||
|
forwards: BTreeMap<u16, PortForward>,
|
||||||
|
conflicts: BTreeMap<u16, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeState {
|
||||||
|
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
active_ports: self
|
||||||
|
.forwards
|
||||||
|
.values()
|
||||||
|
.map(|f| BridgedPort {
|
||||||
|
port: f.port,
|
||||||
|
family: f.family,
|
||||||
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
conflicts: self
|
||||||
|
.conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|(port, reason)| PortConflict {
|
||||||
|
port: *port,
|
||||||
|
reason: reason.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectBridge {
|
||||||
|
/// Distinguishes this poller from a later one for the same project, so a
|
||||||
|
/// poller that exits late can't remove its replacement's map entry.
|
||||||
|
epoch: u64,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
poller: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AuthBridgeManager {
|
||||||
|
bridges: BridgeMap,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling for `project_id`. Idempotent: a call while a live poller
|
||||||
|
/// already exists for the project is a no-op.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) {
|
||||||
|
let mut map = self.bridges.lock().await;
|
||||||
|
|
||||||
|
// A finished poller has already torn its ports down, so its entry is
|
||||||
|
// just a husk and can be replaced. A live one means we're already on.
|
||||||
|
if map
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|b| !b.poller.is_finished())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: starting for project {} (container {})",
|
||||||
|
project_id,
|
||||||
|
&container_id[..container_id.len().min(12)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let poller = tokio::spawn(poll_loop(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
epoch,
|
||||||
|
app,
|
||||||
|
store,
|
||||||
|
state.clone(),
|
||||||
|
self.bridges.clone(),
|
||||||
|
cancel_rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
project_id,
|
||||||
|
ProjectBridge {
|
||||||
|
epoch,
|
||||||
|
cancel: cancel_tx,
|
||||||
|
state,
|
||||||
|
poller,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the bridge for one project and wait until every host port it held
|
||||||
|
/// has been released.
|
||||||
|
pub async fn stop(&self, project_id: &str) {
|
||||||
|
// Remove under the lock, then release it before awaiting: the poller
|
||||||
|
// takes the same lock to deregister itself on exit.
|
||||||
|
let bridge = self.bridges.lock().await.remove(project_id);
|
||||||
|
if let Some(bridge) = bridge {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every bridge. Used on app exit.
|
||||||
|
pub async fn stop_all(&self) {
|
||||||
|
let bridges: Vec<(String, ProjectBridge)> =
|
||||||
|
self.bridges.lock().await.drain().collect();
|
||||||
|
for (project_id, bridge) in bridges {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status. `enabled` comes from the persisted project record, so a
|
||||||
|
/// project whose bridge is on but whose container is stopped still reports
|
||||||
|
/// `enabled: true` with no active ports.
|
||||||
|
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
// Clone the per-project handle out and drop the map lock before taking
|
||||||
|
// the state lock. Holding both across the nested await is not a
|
||||||
|
// deadlock — the order is consistently bridges→state — but it puts a
|
||||||
|
// cheap UI status call behind whatever the poller is doing under
|
||||||
|
// `state`, and behind every other project's status call too.
|
||||||
|
let state = self.bridges.lock().await.get(project_id).map(|b| b.state.clone());
|
||||||
|
match state {
|
||||||
|
Some(state) => state.lock().await.snapshot(enabled),
|
||||||
|
None => AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
..AuthBridgeStatus::disabled()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Poller
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn poll_loop(
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
epoch: u64,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
bridges: BridgeMap,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
let mut exec_failures: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Stop conditions checked every tick, so the bridge winds itself down
|
||||||
|
// even when nothing calls `stop()` (container died, project deleted
|
||||||
|
// out from under us, flag flipped off by another path).
|
||||||
|
let project = match store.get(&project_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !project.auth_bridge_enabled {
|
||||||
|
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One exec per tick reads both procfs files.
|
||||||
|
//
|
||||||
|
// Absolute path, deliberately: the image's `ENV PATH` puts a
|
||||||
|
// container-writable directory first, so a bare `cat` is a name the
|
||||||
|
// container can rebind to a shim that prints whatever it likes. It
|
||||||
|
// still could not make us bind a *non-loopback* port, but it decides
|
||||||
|
// how much output this loop ingests and how many host ports it is asked
|
||||||
|
// for, which is why the call is also length-capped and the result
|
||||||
|
// count is capped in `reconcile`.
|
||||||
|
let cmd = vec![
|
||||||
|
"/usr/bin/cat".to_string(),
|
||||||
|
"/proc/net/tcp".to_string(),
|
||||||
|
"/proc/net/tcp6".to_string(),
|
||||||
|
];
|
||||||
|
// Cancellation races the exec, not just the sleep, so disabling the
|
||||||
|
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||||
|
let discovery = tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||||
|
};
|
||||||
|
|
||||||
|
match discovery {
|
||||||
|
Ok(text) => {
|
||||||
|
exec_failures = 0;
|
||||||
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||||
|
let skip = skipped_ports(&project);
|
||||||
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
|
emit_status(&app, &project_id, &state, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
exec_failures += 1;
|
||||||
|
// Transient failures happen (container restarting, engine busy);
|
||||||
|
// only complain once per streak.
|
||||||
|
if exec_failures == 1 {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
_ = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown(&project_id, &state).await;
|
||||||
|
emit_status(
|
||||||
|
&app,
|
||||||
|
&project_id,
|
||||||
|
&state,
|
||||||
|
store
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|p| p.auth_bridge_enabled),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Deregister, unless a newer poller has already taken this project's slot.
|
||||||
|
let mut map = bridges.lock().await;
|
||||||
|
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
|
||||||
|
map.remove(&project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports Docker already handles for this project. A container port that is
|
||||||
|
/// explicitly published has a host-side path already, and the mapping's host
|
||||||
|
/// port is a binding we must not fight over.
|
||||||
|
///
|
||||||
|
/// [`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> {
|
||||||
|
let mut skip: HashSet<u16> = project
|
||||||
|
.port_mappings
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
|
.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
|
||||||
|
/// listening on. Returns whether anything the UI cares about changed.
|
||||||
|
async fn reconcile(
|
||||||
|
container_id: &str,
|
||||||
|
discovered: &BTreeMap<u16, PortFamily>,
|
||||||
|
skip: &HashSet<u16>,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
|
// Drop host listeners whose container-side counterpart vanished, became
|
||||||
|
// covered by an explicit port mapping, or changed address family (a family
|
||||||
|
// change alters the socat target, so it has to be rebound below).
|
||||||
|
let stale: Vec<u16> = st
|
||||||
|
.forwards
|
||||||
|
.iter()
|
||||||
|
.filter(|(port, forward)| match discovered.get(port) {
|
||||||
|
None => true,
|
||||||
|
Some(_) if skip.contains(port) => true,
|
||||||
|
Some(family) => *family != forward.family,
|
||||||
|
})
|
||||||
|
.map(|(port, _)| *port)
|
||||||
|
.collect();
|
||||||
|
for port in stale {
|
||||||
|
if let Some(mut forward) = st.forwards.remove(&port) {
|
||||||
|
forward.shutdown().await;
|
||||||
|
log::info!("Auth bridge: released host port {}", port);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget conflicts for ports that are no longer relevant.
|
||||||
|
let before = st.conflicts.len();
|
||||||
|
st.conflicts
|
||||||
|
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
|
||||||
|
changed |= st.conflicts.len() != before;
|
||||||
|
|
||||||
|
for (&port, &family) in discovered {
|
||||||
|
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if st.forwards.len() >= MAX_FORWARDS {
|
||||||
|
// Don't even attempt the bind: the point of the cap is to stop the
|
||||||
|
// container dictating how many host resources we take.
|
||||||
|
changed |= note_conflict(
|
||||||
|
&mut st,
|
||||||
|
port,
|
||||||
|
format!(
|
||||||
|
"The auth bridge is already holding {} ports for this project; \
|
||||||
|
{} was not bridged.",
|
||||||
|
MAX_FORWARDS, port
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||||
|
Ok(forward) => {
|
||||||
|
if st.conflicts.remove(&port).is_some() {
|
||||||
|
log::info!("Auth bridge: host port {} became available", port);
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
|
||||||
|
port,
|
||||||
|
family.socat_target(port),
|
||||||
|
family
|
||||||
|
);
|
||||||
|
st.forwards.insert(port, forward);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Conflict policy: never fight for a port. Something else on the
|
||||||
|
// host owns it — another project's bridge, or an unrelated
|
||||||
|
// process. Skip it, record why so the UI can say so, and retry
|
||||||
|
// on later ticks in case the owner releases it. Warn only on
|
||||||
|
// the transition so a long-lived conflict doesn't spam the log.
|
||||||
|
let reason = format!(
|
||||||
|
"Host port {} is already in use ({}); not bridged.",
|
||||||
|
port, e
|
||||||
|
);
|
||||||
|
if st.conflicts.get(&port) != Some(&reason) {
|
||||||
|
log::warn!("Auth bridge: {}", reason);
|
||||||
|
}
|
||||||
|
changed |= note_conflict(&mut st, port, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record why a port wasn't bridged, up to [`MAX_CONFLICTS`]. Returns whether
|
||||||
|
/// the recorded set changed.
|
||||||
|
fn note_conflict(state: &mut BridgeState, port: u16, reason: String) -> bool {
|
||||||
|
match state.conflicts.get(&port) {
|
||||||
|
Some(existing) if *existing == reason => false,
|
||||||
|
Some(_) => {
|
||||||
|
state.conflicts.insert(port, reason);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None if state.conflicts.len() < MAX_CONFLICTS => {
|
||||||
|
state.conflicts.insert(port, reason);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||||
|
/// return nothing is bound.
|
||||||
|
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
let forwards = std::mem::take(&mut st.forwards);
|
||||||
|
st.conflicts.clear();
|
||||||
|
let count = forwards.len();
|
||||||
|
for (_, mut forward) in forwards {
|
||||||
|
forward.shutdown().await;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: released {} host port(s) for project {}",
|
||||||
|
count,
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_status(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
let status = state.lock().await.snapshot(enabled);
|
||||||
|
let _ = app.emit(
|
||||||
|
AUTH_BRIDGE_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"status": status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
|
let mut p = Project::new(
|
||||||
|
"test".to_string(),
|
||||||
|
vec![ProjectPath {
|
||||||
|
host_path: "/tmp".to_string(),
|
||||||
|
mount_name: "tmp".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
p.port_mappings = mappings
|
||||||
|
.into_iter()
|
||||||
|
.map(|(host_port, container_port)| PortMapping {
|
||||||
|
host_port,
|
||||||
|
container_port,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ports_already_published_by_docker_are_skipped() {
|
||||||
|
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
|
// is already reachable, and the host port is Docker's binding.
|
||||||
|
assert!(skip.contains(&8080));
|
||||||
|
assert!(skip.contains(&8081));
|
||||||
|
assert!(!skip.contains(&34567));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
|
||||||
|
//! `/proc/net/tcp6` from inside the container.
|
||||||
|
//!
|
||||||
|
//! ## Why /proc and not `ss`
|
||||||
|
//!
|
||||||
|
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
|
||||||
|
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
|
||||||
|
//! and needs no package at all, so discovery works in the stock image and in any
|
||||||
|
//! snapshot derived from it.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! Both files are fixed-column text with a header line:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
|
||||||
|
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
|
||||||
|
//!
|
||||||
|
//! ## Hex and endianness
|
||||||
|
//!
|
||||||
|
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
|
||||||
|
//! the same way:
|
||||||
|
//!
|
||||||
|
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
|
||||||
|
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
|
||||||
|
//! order*, which is little-endian on every platform this app targets. So each
|
||||||
|
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
|
||||||
|
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
|
||||||
|
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
|
||||||
|
//!
|
||||||
|
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
|
||||||
|
//! digits), each converted independently, in order, to fill the 16 address
|
||||||
|
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
|
||||||
|
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
|
||||||
|
//!
|
||||||
|
//! ## What counts as loopback
|
||||||
|
//!
|
||||||
|
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
|
||||||
|
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
|
||||||
|
//! outside world — that is the port-mappings feature's job, not the auth
|
||||||
|
//! bridge's — so those rows are dropped.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The `st` column value for `TCP_LISTEN`.
|
||||||
|
const TCP_LISTEN: &str = "0A";
|
||||||
|
|
||||||
|
/// Which loopback address family (or families) a container-side listener was
|
||||||
|
/// found on. Determines the `socat` target address used to reach it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PortFamily {
|
||||||
|
/// Only `127.0.0.0/8`.
|
||||||
|
V4,
|
||||||
|
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
|
||||||
|
/// on Linux, so `claude login` frequently binds `::1` and nothing else
|
||||||
|
/// (anthropics/claude-code#44844).
|
||||||
|
V6,
|
||||||
|
/// Both — reachable either way; we use IPv4.
|
||||||
|
Dual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortFamily {
|
||||||
|
fn merge(self, other: PortFamily) -> PortFamily {
|
||||||
|
if self == other {
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
PortFamily::Dual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `socat` address that reaches this listener from inside the container.
|
||||||
|
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
|
||||||
|
/// (verified: connect gets ECONNREFUSED), hence the split.
|
||||||
|
pub fn socat_target(&self, port: u16) -> String {
|
||||||
|
match self {
|
||||||
|
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
|
||||||
|
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed LISTEN row that survived the loopback filter.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct LoopbackListener {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
|
||||||
|
/// the set of loopback ports being listened on, keyed by port with the families
|
||||||
|
/// merged (a port bound on both `127.0.0.1` and `::1` yields
|
||||||
|
/// [`PortFamily::Dual`]).
|
||||||
|
///
|
||||||
|
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
|
||||||
|
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
|
||||||
|
/// combined output — are silently ignored rather than failing the whole poll.
|
||||||
|
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
|
||||||
|
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
|
||||||
|
for listener in parse_listener_rows(text) {
|
||||||
|
ports
|
||||||
|
.entry(listener.port)
|
||||||
|
.and_modify(|f| *f = f.merge(listener.family))
|
||||||
|
.or_insert(listener.family);
|
||||||
|
}
|
||||||
|
ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row-level parse, before per-port family merging. Split out so tests can
|
||||||
|
/// assert on the individual rows.
|
||||||
|
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
|
||||||
|
text.lines().filter_map(parse_listener_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let _sl = fields.next()?;
|
||||||
|
let local_address = fields.next()?;
|
||||||
|
let _rem_address = fields.next()?;
|
||||||
|
let state = fields.next()?;
|
||||||
|
|
||||||
|
if state != TCP_LISTEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (addr_hex, port_hex) = local_address.split_once(':')?;
|
||||||
|
// The port is a straightforward big-endian hex u16 — no byte swapping.
|
||||||
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||||
|
if port == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match addr_hex.len() {
|
||||||
|
8 => {
|
||||||
|
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
|
||||||
|
addr.is_loopback().then_some(PortFamily::V4)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
|
||||||
|
let group = std::str::from_utf8(group).ok()?;
|
||||||
|
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
|
||||||
|
}
|
||||||
|
let addr = Ipv6Addr::from(octets);
|
||||||
|
// An IPv4-mapped row describes a v4 socket, so it is reachable at
|
||||||
|
// 127.0.0.1 and must be classified as v4, not v6.
|
||||||
|
match addr.to_ipv4_mapped() {
|
||||||
|
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
|
||||||
|
None => addr.is_loopback().then_some(PortFamily::V6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(LoopbackListener { port, family })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `%08X` procfs address word into its four address bytes in network
|
||||||
|
/// order. The kernel prints the word in host byte order, so the recovered bytes
|
||||||
|
/// are the little-endian expansion of the parsed integer.
|
||||||
|
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
|
||||||
|
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
|
||||||
|
/// with three listeners deliberately started:
|
||||||
|
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
|
||||||
|
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
|
||||||
|
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
|
||||||
|
const REAL_PROC_NET_TCP: &str = concat!(
|
||||||
|
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
|
||||||
|
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
|
||||||
|
/// the Node listener bound to `::1` only — the case that motivates the
|
||||||
|
/// TCP6 socat target.
|
||||||
|
const REAL_PROC_NET_TCP6: &str = concat!(
|
||||||
|
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||||
|
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
fn both_files() -> String {
|
||||||
|
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv4_loopback_row_with_little_endian_address() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
|
||||||
|
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 0x8707,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(rows[0].port, 34567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv6_loopback_row() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34568,
|
||||||
|
family: PortFamily::V6
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_wildcard_bind_addresses() {
|
||||||
|
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
|
||||||
|
// the port-mappings feature's territory.
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert!(!ports.contains_key(&34569));
|
||||||
|
|
||||||
|
// Same for the IPv6 wildcard and a non-loopback unicast address.
|
||||||
|
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(wildcard_v6).is_empty());
|
||||||
|
assert!(parse_listener_rows(lan_v4).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_both_files_concatenated_as_one_exec_output() {
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert_eq!(ports.len(), 2);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
|
||||||
|
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_families_for_a_dual_stack_port() {
|
||||||
|
let dual = format!(
|
||||||
|
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
|
||||||
|
both_files()
|
||||||
|
);
|
||||||
|
let ports = parse_loopback_listeners(&dual);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_mapped_loopback_is_reported_as_v4() {
|
||||||
|
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
|
||||||
|
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert_eq!(
|
||||||
|
parse_listener_rows(row),
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34567,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_listen_states() {
|
||||||
|
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
|
||||||
|
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(established).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_headers_and_garbage() {
|
||||||
|
assert!(parse_listener_rows("").is_empty());
|
||||||
|
assert!(parse_listener_rows(
|
||||||
|
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
// Truncated / malformed rows must not panic or be accepted.
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socat_target_matches_family() {
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::V4.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::Dual.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! Host-side loopback listener for one bridged port, and the per-connection
|
||||||
|
//! tunnel that carries its bytes into the container.
|
||||||
|
//!
|
||||||
|
//! ## Why not connect to the container's IP
|
||||||
|
//!
|
||||||
|
//! Container IPs are not routable from the host on Docker Desktop (macOS and
|
||||||
|
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
|
||||||
|
//! transport. The Docker API is the only channel guaranteed to reach the
|
||||||
|
//! container from the host, so each accepted connection is carried by a
|
||||||
|
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
|
||||||
|
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||||
|
//!
|
||||||
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
|
//! interactive terminal sessions are built on.
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||||
|
|
||||||
|
use super::proc_net::PortFamily;
|
||||||
|
|
||||||
|
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||||
|
/// only needs to not be pathological.
|
||||||
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
|
/// child running.
|
||||||
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
|
|
||||||
|
impl Drop for AbortOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One host loopback port bound and proxied into the container.
|
||||||
|
///
|
||||||
|
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
|
||||||
|
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
|
||||||
|
/// releases the port *and* tears down every connection under it. [`Drop`] does
|
||||||
|
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
|
||||||
|
/// also awaiting the aborted task, which guarantees the socket is closed before
|
||||||
|
/// the caller proceeds (important when a port is rebound right after).
|
||||||
|
pub struct PortForward {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
pub bridged_at: String,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForward {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortForward {
|
||||||
|
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||||
|
///
|
||||||
|
/// The bind happens before the task is spawned, so an already-taken port is
|
||||||
|
/// reported to the caller as an error rather than disappearing into a
|
||||||
|
/// background task.
|
||||||
|
pub async fn bind(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
family: PortFamily,
|
||||||
|
) -> Result<Self, std::io::Error> {
|
||||||
|
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
|
||||||
|
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
|
||||||
|
// an unauthenticated service inside the container that deliberately
|
||||||
|
// bound loopback because it expected to be reachable from nowhere else.
|
||||||
|
// Binding a wildcard address here would publish container internals to
|
||||||
|
// every host on the LAN. Do not "fix" a connectivity problem by
|
||||||
|
// widening these addresses.
|
||||||
|
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
|
||||||
|
|
||||||
|
// Also take ::1 when it is available. Browsers and CLIs resolve
|
||||||
|
// `localhost` to either family, and the IPv6 answer is often tried
|
||||||
|
// first, so a v4-only host listener would miss those callbacks. This is
|
||||||
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||||
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||||
|
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => Some(l),
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||||
|
port,
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = family.socat_target(port);
|
||||||
|
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
family,
|
||||||
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting, drop the host socket, and abort every in-flight
|
||||||
|
/// connection. Awaits the aborted task so the port is provably released
|
||||||
|
/// when this returns.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||||
|
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||||
|
async fn accept_loop(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
target: String,
|
||||||
|
v4: TcpListener,
|
||||||
|
v6: Option<TcpListener>,
|
||||||
|
) {
|
||||||
|
let mut conns: JoinSet<()> = JoinSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
r = v4.accept() => r,
|
||||||
|
r = accept_optional(v6.as_ref()) => r,
|
||||||
|
// Reap finished tunnels so the JoinSet doesn't grow without bound.
|
||||||
|
// When the set is empty `join_next()` yields None, the pattern fails
|
||||||
|
// to match, and the branch simply drops out of the select.
|
||||||
|
Some(_) = conns.join_next() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, peer)) => {
|
||||||
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
conns.spawn(tunnel_connection(
|
||||||
|
container_id.clone(),
|
||||||
|
target.clone(),
|
||||||
|
stream,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `accept()` on an optional listener; never completes when there is none, so it
|
||||||
|
/// can sit in a `select!` arm unconditionally.
|
||||||
|
async fn accept_optional(
|
||||||
|
listener: Option<&TcpListener>,
|
||||||
|
) -> std::io::Result<(TcpStream, SocketAddr)> {
|
||||||
|
match listener {
|
||||||
|
Some(l) => l.accept().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry one accepted host connection into the container over `socat`.
|
||||||
|
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 AttachedExec {
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
..
|
||||||
|
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
|
||||||
|
port,
|
||||||
|
target,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut host_rx, mut host_tx) = stream.into_split();
|
||||||
|
|
||||||
|
// Host → container. Runs as its own task so the container→host direction is
|
||||||
|
// never blocked behind a client that has stopped sending. Finishing this
|
||||||
|
// direction drops `input`, which closes the exec's stdin and lets socat see
|
||||||
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||||
|
let upstream = AbortOnDrop(tokio::spawn(async move {
|
||||||
|
// Bytes the caller already consumed from the socket go first, so the
|
||||||
|
// container sees the connection exactly as the client sent it.
|
||||||
|
if !prelude.is_empty()
|
||||||
|
&& (input.write_all(&prelude).await.is_err() || input.flush().await.is_err())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
|
loop {
|
||||||
|
match host_rx.read(&mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
|
// stream ends, socat has exited and the connection is over.
|
||||||
|
while let Some(chunk) = output.next().await {
|
||||||
|
match chunk {
|
||||||
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
|
// diagnostics out of the proxied byte stream.
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if host_tx.write_all(&message).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: socat stderr for port {}: {}",
|
||||||
|
port,
|
||||||
|
String::from_utf8_lossy(&message).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = host_tx.shutdown().await;
|
||||||
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
|
drop(upstream);
|
||||||
|
}
|
||||||
@@ -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,67 @@
|
|||||||
|
//! IPC surface for the auth bridge. The mechanism lives in
|
||||||
|
//! [`crate::auth_bridge`]; this file only translates between it and the
|
||||||
|
//! frontend, and keeps the persisted per-project flag in step.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::auth_bridge::AuthBridgeStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Turn the bridge on or off for a project and return the resulting status.
|
||||||
|
///
|
||||||
|
/// Enabling starts polling immediately when the container is already running;
|
||||||
|
/// otherwise the flag is simply persisted and `start_project_container` arms the
|
||||||
|
/// bridge on the next start. This is a host-side feature, so no container
|
||||||
|
/// recreation is involved either way.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auth_bridge_enabled(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, enabled)?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
if let Some(container_id) = project.container_id {
|
||||||
|
if crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Awaits the poller, so every host port is released before we return.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_bridge_status(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
let enabled = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.auth_bridge_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
@@ -155,11 +155,12 @@ pub async fn download_container_file(
|
|||||||
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
||||||
/// The archive contains:
|
/// The archive contains:
|
||||||
/// - the workspace (default /workspace), minus regenerable build artifacts
|
/// - the workspace (default /workspace), minus regenerable build artifacts
|
||||||
/// (node_modules, target), at the archive root, and
|
/// (node_modules, target), under `workspace/`, and
|
||||||
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
||||||
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
|
/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native
|
||||||
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
|
/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
|
||||||
/// set up via Claude Code survive a Reset.
|
/// `.credentials.json`, so settings and skills set up via Claude Code
|
||||||
|
/// survive a Reset.
|
||||||
/// `.git` is kept in full so the backup faithfully preserves git history,
|
/// `.git` is kept in full so the backup faithfully preserves git history,
|
||||||
/// including unpushed commits. Build + gzip happen inside the container so a
|
/// including unpushed commits. Build + gzip happen inside the container so a
|
||||||
/// large workspace isn't streamed in full. The container must be RUNNING (the
|
/// large workspace isn't streamed in full. The container must be RUNNING (the
|
||||||
@@ -204,6 +205,16 @@ pub async fn download_container_backup(
|
|||||||
// transient unreadable file from aborting the whole backup. If jq can't
|
// transient unreadable file from aborting the whole backup. If jq can't
|
||||||
// parse ~/.claude.json we substitute an empty object — never the raw file —
|
// parse ~/.claude.json we substitute an empty object — never the raw file —
|
||||||
// so secrets can't leak through the sanitization fallback.
|
// so secrets can't leak through the sanitization fallback.
|
||||||
|
// The `--transform` nests the workspace under `workspace/` (parallel to
|
||||||
|
// `home-claude/`) so an extracted archive has both clearly labeled instead
|
||||||
|
// of scattering the workspace files into the extraction dir. Rewriting the
|
||||||
|
// leading `.` (rather than `./`) also renames tar's root member from `./` to
|
||||||
|
// `workspace`, so the archive carries a proper `workspace/` dir entry rather
|
||||||
|
// than a bare `./` that would stamp the source root's mode/mtime onto the
|
||||||
|
// extraction directory. `flags=rh` rewrites regular member names AND
|
||||||
|
// hardlink target names (so an intra-workspace hardlink pair still resolves
|
||||||
|
// on extract) while leaving symlink targets untouched (rewriting those would
|
||||||
|
// corrupt relative/absolute links).
|
||||||
let script = r#"set -e
|
let script = r#"set -e
|
||||||
STAGE=$(mktemp -d)
|
STAGE=$(mktemp -d)
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
@@ -221,6 +232,7 @@ if [ -d "$HOME/.claude" ]; then
|
|||||||
fi
|
fi
|
||||||
tar czf - --ignore-failed-read \
|
tar czf - --ignore-failed-read \
|
||||||
--exclude='*/node_modules' --exclude='*/target' \
|
--exclude='*/node_modules' --exclude='*/target' \
|
||||||
|
--transform='flags=rh;s,^\.,workspace,' \
|
||||||
-C "$TC_BACKUP_SRC" . \
|
-C "$TC_BACKUP_SRC" . \
|
||||||
-C "$STAGE" home-claude"#;
|
-C "$STAGE" home-claude"#;
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::models::McpServer;
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
|
|
||||||
Ok(state.mcp_store.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn add_mcp_server(
|
|
||||||
name: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<McpServer, String> {
|
|
||||||
let name = name.trim().to_string();
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err("MCP server name cannot be empty.".to_string());
|
|
||||||
}
|
|
||||||
let server = McpServer::new(name);
|
|
||||||
state.mcp_store.add(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_mcp_server(
|
|
||||||
server: McpServer,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<McpServer, String> {
|
|
||||||
state.mcp_store.update(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn remove_mcp_server(
|
|
||||||
server_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.mcp_store.remove(&server_id)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
pub mod auth_bridge_commands;
|
||||||
|
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 install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
pub mod mcp_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, McpServer, 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 {
|
||||||
@@ -63,19 +105,6 @@ fn load_secrets_for_project(project: &mut Project) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve enabled MCP servers and filter to Docker-only ones.
|
|
||||||
fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec<McpServer>, Vec<McpServer>) {
|
|
||||||
let all_mcp_servers = state.mcp_store.list();
|
|
||||||
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
|
|
||||||
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
|
|
||||||
.collect();
|
|
||||||
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
|
|
||||||
.filter(|s| s.is_docker())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
(enabled_mcp, docker_mcp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||||
Ok(state.projects_store.list())
|
Ok(state.projects_store.list())
|
||||||
@@ -113,6 +142,15 @@ pub async fn remove_project(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Release any host loopback ports the auth bridge holds for this project
|
||||||
|
// before the container (and the project record) go away.
|
||||||
|
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 {
|
||||||
@@ -121,16 +159,10 @@ pub async fn remove_project(
|
|||||||
let _ = docker::remove_container(container_id).await;
|
let _ = docker::remove_container(container_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove MCP containers and network
|
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state);
|
// containers first, then the per-project network they were attached to.
|
||||||
if !docker_mcp.is_empty() {
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = docker::remove_project_network(&project.id).await {
|
|
||||||
log::warn!("Failed to remove project network for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the snapshot image + volumes
|
// Clean up the snapshot image + volumes
|
||||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||||
@@ -152,10 +184,35 @@ pub async fn remove_project(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_project(
|
pub async fn update_project(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
store_secrets_for_project(&project)?;
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.update(project)
|
let updated = state.projects_store.update(project)?;
|
||||||
|
|
||||||
|
// `auth_bridge_enabled` can arrive through this generic save as well as
|
||||||
|
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
||||||
|
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
||||||
|
// when nothing is running, so this is safe on every project save.
|
||||||
|
if updated.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = updated.container_id {
|
||||||
|
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
updated.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.auth_bridge.stop(&updated.id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -164,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)
|
||||||
@@ -177,9 +248,6 @@ pub async fn start_project_container(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Resolve enabled MCP servers for this project
|
|
||||||
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
|
||||||
|
|
||||||
// Validate backend requirements
|
// Validate backend requirements
|
||||||
if project.backend == Backend::Bedrock {
|
if project.backend == Backend::Bedrock {
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
@@ -200,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())?;
|
||||||
@@ -300,53 +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();
|
||||||
|
|
||||||
// Set up Docker network and MCP containers if needed
|
// What we would create this container from *right now*: the project's
|
||||||
let network_name = if !docker_mcp.is_empty() {
|
// snapshot when one exists, else the configured base. This is the value
|
||||||
// Pull any missing MCP Docker images before starting containers
|
// `container_needs_recreation` compares against the container's
|
||||||
for server in &docker_mcp {
|
// `triple-c.create-image` label — the check that replaced the old
|
||||||
if let Some(ref image) = server.docker_image {
|
// tautological one. It is resolved *before* the commit below, so it
|
||||||
if !docker::image_exists(image).await.unwrap_or(false) {
|
// describes the pre-commit world the existing container was born into.
|
||||||
emit_progress(
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||||
&app_handle,
|
let expected_create_image =
|
||||||
&project_id,
|
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
&format!("Pulling MCP image for '{}'...", server.name),
|
snapshot_image.clone()
|
||||||
);
|
} else {
|
||||||
let image_clone = image.clone();
|
image_name.clone()
|
||||||
let app_clone = app_handle.clone();
|
};
|
||||||
let pid_clone = project_id.clone();
|
|
||||||
let sname = server.name.clone();
|
|
||||||
docker::pull_image(&image_clone, move |msg| {
|
|
||||||
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
|
|
||||||
}).await.map_err(|e| {
|
|
||||||
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
|
||||||
let net = docker::ensure_project_network(&project.id).await?;
|
|
||||||
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
|
||||||
docker::start_mcp_containers(&docker_mcp, &net).await?;
|
|
||||||
Some(net)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
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(),
|
||||||
&enabled_mcp,
|
|
||||||
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);
|
||||||
@@ -362,31 +423,30 @@ pub async fn start_project_container(
|
|||||||
let _ = docker::stop_container(&existing_id).await;
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
|
|
||||||
// Create from snapshot image (preserves system-level changes)
|
// Legacy MCP cleanup: the old container may have been attached to
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||||
|
// that network now, before the replacement is created without it.
|
||||||
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
|
|
||||||
|
// Create from snapshot image (preserves system-level changes).
|
||||||
|
// 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(),
|
|
||||||
&enabled_mcp,
|
|
||||||
network_name.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?;
|
||||||
@@ -400,32 +460,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(),
|
|
||||||
&enabled_mcp,
|
|
||||||
network_name.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?;
|
||||||
@@ -454,6 +502,20 @@ pub async fn start_project_container(
|
|||||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
||||||
|
|
||||||
|
// Arm the auth bridge if this project opted in. Purely host-side, so it
|
||||||
|
// happens after the container is up and never affects the start itself.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
project.container_id = Some(container_id);
|
project.container_id = Some(container_id);
|
||||||
project.status = ProjectStatus::Running;
|
project.status = ProjectStatus::Running;
|
||||||
Ok(project)
|
Ok(project)
|
||||||
@@ -472,6 +534,9 @@ pub async fn stop_project_container(
|
|||||||
|
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
|
|
||||||
|
// Drop host listeners first: they only make sense while the container runs.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||||
@@ -482,15 +547,6 @@ pub async fn stop_project_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop MCP containers (best-effort)
|
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
|
||||||
if !docker_mcp.is_empty() {
|
|
||||||
emit_progress(&app_handle, &project_id, "Stopping MCP containers...");
|
|
||||||
if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await {
|
|
||||||
log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -501,11 +557,32 @@ 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;
|
||||||
|
// `start_project_container` below re-arms it against the new one.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// Remove existing container
|
// Remove existing container
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
@@ -514,14 +591,6 @@ pub async fn rebuild_project_container(
|
|||||||
state.projects_store.set_container_id(&project_id, None)?;
|
state.projects_store.set_container_id(&project_id, None)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove MCP containers before rebuild
|
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
|
||||||
if !docker_mcp.is_empty() {
|
|
||||||
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
|
|
||||||
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove snapshot image + volumes so Reset creates from the clean base image
|
// Remove snapshot image + volumes so Reset creates from the clean base image
|
||||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||||
@@ -538,14 +607,46 @@ 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,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Project>, String> {
|
) -> Result<Vec<Project>, String> {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +662,22 @@ pub async fn reconcile_project_statuses(
|
|||||||
project.name,
|
project.name,
|
||||||
project.id
|
project.id
|
||||||
);
|
);
|
||||||
|
// The app may have restarted while the container kept running; the
|
||||||
|
// bridge lives in this process, so re-arm it here. `start` is
|
||||||
|
// idempotent, so a bridge that is already polling is untouched.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = project.container_id {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Project '{}' ({}) container is not running — setting to Stopped",
|
"Project '{}' ({}) container is not running — setting to Stopped",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
|||||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
let mut cmd = vec!["claude".to_string()];
|
||||||
if project.full_permissions {
|
cmd.extend(permission_args);
|
||||||
cmd.push("--dangerously-skip-permissions".to_string());
|
|
||||||
}
|
|
||||||
if let Some(name) = session_name {
|
if let Some(name) = session_name {
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
cmd.push("-n".to_string());
|
cmd.push("-n".to_string());
|
||||||
@@ -42,11 +42,13 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
|||||||
.filter(|n| !n.is_empty())
|
.filter(|n| !n.is_empty())
|
||||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let claude_cmd = if project.full_permissions {
|
// The args are interpolated into a shell script string, so single-quote
|
||||||
format!("exec claude --dangerously-skip-permissions{}", name_flag)
|
// each one (same escaping style as name_flag above).
|
||||||
} else {
|
let permission_flags: String = permission_args
|
||||||
format!("exec claude{}", name_flag)
|
.iter()
|
||||||
};
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
//! Corporate CA certificate injection.
|
||||||
|
//!
|
||||||
|
//! Users behind a TLS-terminating corporate proxy need their organisation's
|
||||||
|
//! root CA inside every container, or **every** HTTPS call fails — npm, pip,
|
||||||
|
//! git, curl, the Playwright browser, and Claude Code's own API calls.
|
||||||
|
//!
|
||||||
|
//! The mechanism follows the SSH/AWS host-mount pattern in [`super::container`]:
|
||||||
|
//! a host path is bind-mounted **read-only** into the container and
|
||||||
|
//! `entrypoint.sh` applies it on every start. That is what makes it durable
|
||||||
|
//! across container recreation, base-image migration and Reset — a certificate
|
||||||
|
//! installed by hand inside a running container is lost the first time any of
|
||||||
|
//! those happen.
|
||||||
|
//!
|
||||||
|
//! ## Two things that are easy to get wrong
|
||||||
|
//!
|
||||||
|
//! 1. **`update-ca-certificates` only reads `*.crt`.** It globs
|
||||||
|
//! `/usr/local/share/ca-certificates/*.crt` case-sensitively, so a `.pem`
|
||||||
|
//! (the far more common export format) that is merely *copied* in is
|
||||||
|
//! silently ignored — no warning, no error, just a container that still
|
||||||
|
//! cannot speak HTTPS. Certificates must be **renamed**, which is what
|
||||||
|
//! [`container_cert_name`] does.
|
||||||
|
//!
|
||||||
|
//! 2. **The system trust store is not enough.** Only curl/git/apt read it.
|
||||||
|
//! Node — and therefore Claude Code itself — needs `NODE_EXTRA_CA_CERTS`,
|
||||||
|
//! Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`, and
|
||||||
|
//! Chrome/Chromium read neither: they have their own NSS database at
|
||||||
|
//! `~/.pki/nssdb`, seeded by `certutil` in the entrypoint.
|
||||||
|
//!
|
||||||
|
//! ## Why the env vars are set from Rust and not exported by the entrypoint
|
||||||
|
//!
|
||||||
|
//! An `export` in `entrypoint.sh` reaches only the entrypoint's own children.
|
||||||
|
//! Every terminal session is a separate `docker exec`, which inherits the
|
||||||
|
//! *container's* configured env and sees nothing the entrypoint exported —
|
||||||
|
//! the same lesson that forced `$BROWSER` to become an image-level `ENV` for
|
||||||
|
//! the URL relay shim. Since the bundle path written by
|
||||||
|
//! `update-ca-certificates` is deterministic ([`CA_BUNDLE_PATH`]), Rust can set
|
||||||
|
//! all three vars at container creation, where `docker exec` will see them.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Where the host's CA material is bind-mounted, read-only. Mirrors
|
||||||
|
/// `/tmp/.host-ssh` and `/tmp/.host-aws`.
|
||||||
|
///
|
||||||
|
/// A *directory* on the host is mounted here as-is. A single *file* is mounted
|
||||||
|
/// at `<CA_MOUNT_DIR>/<normalised name>` — Docker creates the parent — so the
|
||||||
|
/// entrypoint only ever has to deal with a directory, and the certificate keeps
|
||||||
|
/// a recognisable name instead of becoming the literal path `.host-ca`.
|
||||||
|
pub const CA_MOUNT_DIR: &str = "/tmp/.host-ca";
|
||||||
|
|
||||||
|
/// The concatenated PEM bundle `update-ca-certificates` writes on
|
||||||
|
/// Debian/Ubuntu. Deterministic, which is what lets the env vars below be set
|
||||||
|
/// at container-creation time, before the entrypoint has run.
|
||||||
|
pub const CA_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt";
|
||||||
|
|
||||||
|
/// Consulted by Node — and therefore by Claude Code itself, which is the whole
|
||||||
|
/// reason this feature exists.
|
||||||
|
pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS";
|
||||||
|
/// Consulted by `requests` (and so by pip's vendored copy).
|
||||||
|
pub const REQUESTS_CA_BUNDLE: &str = "REQUESTS_CA_BUNDLE";
|
||||||
|
/// Consulted by OpenSSL, and so by Python's `ssl` module.
|
||||||
|
pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE";
|
||||||
|
|
||||||
|
/// Every env var this module owns, in a fixed order.
|
||||||
|
///
|
||||||
|
/// Also the list that must be *cleared* when no CA is configured: `docker
|
||||||
|
/// commit` bakes a container's env into the project's snapshot image, and
|
||||||
|
/// create-time env replaces image `ENV` per key — so without an explicit empty
|
||||||
|
/// value, removing the setting would leave the vars live in every future
|
||||||
|
/// container. Empty is safe for all three (verified on Ubuntu 24.04: curl,
|
||||||
|
/// `openssl s_client` and Python's `ssl` all behave exactly as they do with the
|
||||||
|
/// variable unset).
|
||||||
|
pub const CA_ENV_KEYS: &[&str] = &[NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE];
|
||||||
|
|
||||||
|
/// Extensions treated as certificates when the configured path is a directory.
|
||||||
|
/// Matched case-insensitively. DER is deliberately absent — the system store
|
||||||
|
/// and every consumer here want PEM.
|
||||||
|
const CERT_EXTENSIONS: &[&str] = &["crt", "pem", "cer", "cert", "ca-bundle"];
|
||||||
|
|
||||||
|
/// A configured CA path that has been checked and resolved into everything the
|
||||||
|
/// container creation path needs.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedCa {
|
||||||
|
/// The host path, as configured.
|
||||||
|
pub host_path: String,
|
||||||
|
/// Whether the host path is a directory (as opposed to a single file).
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The bind-mount target inside the container.
|
||||||
|
pub mount_target: String,
|
||||||
|
/// The certificate files found, sorted.
|
||||||
|
pub cert_files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name a certificate is installed as under
|
||||||
|
/// `/usr/local/share/ca-certificates/`.
|
||||||
|
///
|
||||||
|
/// `update-ca-certificates` globs `*.crt` **case-sensitively**, so `.pem`,
|
||||||
|
/// `.cer`, `.CRT` and extension-less files all have to end up as a lowercase
|
||||||
|
/// `.crt` or they are ignored without a word. Characters outside
|
||||||
|
/// `[A-Za-z0-9._-]` are replaced so that whitespace cannot break the shell
|
||||||
|
/// loops that walk the store, and leading dots are stripped so a hidden file
|
||||||
|
/// does not stay hidden.
|
||||||
|
///
|
||||||
|
/// `entrypoint.sh` reimplements exactly this in a few lines of shell (it has to
|
||||||
|
/// rename the files inside the container); the two must agree, which is what
|
||||||
|
/// the unit tests below pin down.
|
||||||
|
pub fn container_cert_name(file_name: &str) -> String {
|
||||||
|
let sanitized: String = file_name
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sanitized = sanitized.trim_start_matches('.');
|
||||||
|
// Strip one trailing extension, whatever it is, then force `.crt`. A name
|
||||||
|
// with no dot keeps its whole self as the stem.
|
||||||
|
let stem = match sanitized.rfind('.') {
|
||||||
|
Some(i) => &sanitized[..i],
|
||||||
|
None => sanitized,
|
||||||
|
};
|
||||||
|
let stem = if stem.is_empty() { "corporate-ca" } else { stem };
|
||||||
|
format!("{}.crt", stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a directory entry looks like a certificate worth installing.
|
||||||
|
fn is_cert_file(path: &Path) -> bool {
|
||||||
|
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ext = ext.to_ascii_lowercase();
|
||||||
|
CERT_EXTENSIONS.contains(&ext.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The certificate files a configured path contributes.
|
||||||
|
///
|
||||||
|
/// A file is taken at face value — the user pointed at it explicitly, so its
|
||||||
|
/// extension is not second-guessed. A directory is scanned one level deep
|
||||||
|
/// (matching the entrypoint's `find -maxdepth 1`) and filtered by extension,
|
||||||
|
/// so an `openssl.cnf` or a README sitting next to the certs is skipped.
|
||||||
|
/// The result is sorted, so the fingerprint is stable across filesystem
|
||||||
|
/// enumeration order.
|
||||||
|
pub fn collect_cert_files(path: &Path) -> Vec<PathBuf> {
|
||||||
|
if path.is_file() {
|
||||||
|
return vec![path.to_path_buf()];
|
||||||
|
}
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut files: Vec<PathBuf> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.is_file() && is_cert_file(p))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the configured CA path, or explain why it cannot be used.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` means "no CA configured", which is the overwhelmingly common
|
||||||
|
/// case and must stay free. An `Err` aborts the container start: behind a
|
||||||
|
/// TLS-intercepting proxy a container without the CA is broken in a dozen
|
||||||
|
/// confusing ways, so naming the bad path once is far kinder than letting npm,
|
||||||
|
/// pip and Claude Code each fail their own way.
|
||||||
|
pub fn resolve(path: Option<&str>) -> Result<Option<ResolvedCa>, String> {
|
||||||
|
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let root = Path::new(raw);
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate path '{}' does not exist. Update it in \
|
||||||
|
Settings → Certificates, or clear this project's override in \
|
||||||
|
Project Home → Config → Access.",
|
||||||
|
raw
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = root.is_dir();
|
||||||
|
if !is_dir && !root.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate path '{}' is neither a file nor a directory.",
|
||||||
|
raw
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cert_files = collect_cert_files(root);
|
||||||
|
if cert_files.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Corporate CA certificate directory '{}' contains no certificate files \
|
||||||
|
(looked for {} one level deep).",
|
||||||
|
raw,
|
||||||
|
CERT_EXTENSIONS
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!(".{}", e))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mount_target = if is_dir {
|
||||||
|
CA_MOUNT_DIR.to_string()
|
||||||
|
} else {
|
||||||
|
let name = root
|
||||||
|
.file_name()
|
||||||
|
.map(|n| container_cert_name(&n.to_string_lossy()))
|
||||||
|
.unwrap_or_else(|| "corporate-ca.crt".to_string());
|
||||||
|
format!("{}/{}", CA_MOUNT_DIR, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ResolvedCa {
|
||||||
|
host_path: raw.to_string(),
|
||||||
|
is_dir,
|
||||||
|
mount_target,
|
||||||
|
cert_files,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the CA configuration, for the `triple-c.ca-fingerprint`
|
||||||
|
/// label.
|
||||||
|
///
|
||||||
|
/// `container_needs_recreation` is label-based and never diffs env or mounts,
|
||||||
|
/// so without this, changing the CA path would silently do nothing until some
|
||||||
|
/// unrelated setting forced a rebuild.
|
||||||
|
///
|
||||||
|
/// It covers **both** the resolved path *and the bytes of every certificate*,
|
||||||
|
/// because replacing a rotated CA at the same path is at least as common as
|
||||||
|
/// moving it — and the container's copy is made once, at start, so nothing else
|
||||||
|
/// would notice.
|
||||||
|
///
|
||||||
|
/// Never returns an error: a path that has gone missing hashes differently from
|
||||||
|
/// one that is present, which is exactly the "something changed, recreate"
|
||||||
|
/// signal wanted here. Reporting the problem is [`resolve`]'s job.
|
||||||
|
pub fn compute_ca_fingerprint(path: Option<&str>) -> String {
|
||||||
|
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut parts: Vec<String> = vec![raw.to_string()];
|
||||||
|
let root = Path::new(raw);
|
||||||
|
if !root.exists() {
|
||||||
|
parts.push("<missing>".to_string());
|
||||||
|
} else {
|
||||||
|
for file in collect_cert_files(root) {
|
||||||
|
let name = file
|
||||||
|
.file_name()
|
||||||
|
.map(|n| container_cert_name(&n.to_string_lossy()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let digest = match std::fs::read(&file) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
Err(_) => "<unreadable>".to_string(),
|
||||||
|
};
|
||||||
|
parts.push(format!("{}:{}", name, digest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sha256_hex(&parts.join("|"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The env vars to set on the container.
|
||||||
|
///
|
||||||
|
/// Always returns all of [`CA_ENV_KEYS`]: pointing at the bundle when a CA is
|
||||||
|
/// configured, empty when it is not. The empty case is not cosmetic — see the
|
||||||
|
/// note on [`CA_ENV_KEYS`].
|
||||||
|
pub fn ca_env_vars(resolved: Option<&ResolvedCa>) -> Vec<(&'static str, String)> {
|
||||||
|
let value = if resolved.is_some() { CA_BUNDLE_PATH } else { "" };
|
||||||
|
CA_ENV_KEYS
|
||||||
|
.iter()
|
||||||
|
.map(|key| (*key, value.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// A scratch directory that cleans itself up. `tempfile` is not a
|
||||||
|
/// dependency of this crate and this is the only test that needs one.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new(tag: &str) -> Self {
|
||||||
|
let mut p = std::env::temp_dir();
|
||||||
|
p.push(format!(
|
||||||
|
"triple-c-ca-test-{}-{}-{:?}",
|
||||||
|
tag,
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&p).unwrap();
|
||||||
|
TempDir(p)
|
||||||
|
}
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
fn write(&self, name: &str, contents: &str) -> PathBuf {
|
||||||
|
let p = self.0.join(name);
|
||||||
|
fs::write(&p, contents).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── container_cert_name ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pem_is_renamed_to_crt_not_merely_copied() {
|
||||||
|
// The whole point: update-ca-certificates globs *.crt and would
|
||||||
|
// silently ignore corp-root.pem.
|
||||||
|
assert_eq!(container_cert_name("corp-root.pem"), "corp-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crt_keeps_its_name() {
|
||||||
|
assert_eq!(container_cert_name("corp-root.crt"), "corp-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_certificate_extensions_are_renamed_too() {
|
||||||
|
assert_eq!(container_cert_name("zscaler.cer"), "zscaler.crt");
|
||||||
|
assert_eq!(container_cert_name("zscaler.cert"), "zscaler.crt");
|
||||||
|
assert_eq!(container_cert_name("bundle.ca-bundle"), "bundle.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uppercase_extension_is_lowercased() {
|
||||||
|
// `find -name '*.crt'` is case-sensitive, so CA.CRT would be ignored.
|
||||||
|
assert_eq!(container_cert_name("CA.CRT"), "CA.crt");
|
||||||
|
assert_eq!(container_cert_name("CA.PEM"), "CA.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_without_an_extension_gains_one() {
|
||||||
|
assert_eq!(container_cert_name("corporate-root"), "corporate-root.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_extension_is_replaced() {
|
||||||
|
assert_eq!(container_cert_name("corp.root.ca.pem"), "corp.root.ca.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_characters_are_replaced() {
|
||||||
|
assert_eq!(
|
||||||
|
container_cert_name("Corp Root CA (2026).pem"),
|
||||||
|
"Corp_Root_CA__2026_.crt"
|
||||||
|
);
|
||||||
|
assert_eq!(container_cert_name("a/b.pem"), "a_b.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_dots_are_stripped_so_the_file_is_not_hidden() {
|
||||||
|
assert_eq!(container_cert_name(".hidden.pem"), "hidden.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_degenerate_name_still_produces_a_usable_file() {
|
||||||
|
assert_eq!(container_cert_name(".pem"), "pem.crt");
|
||||||
|
assert_eq!(container_cert_name(""), "corporate-ca.crt");
|
||||||
|
assert_eq!(container_cert_name("..."), "corporate-ca.crt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_produced_name_ends_in_lowercase_crt() {
|
||||||
|
for input in [
|
||||||
|
"a.pem", "b.CRT", "c", ".d.pem", "", "e f.cer", "...", "ç.pem",
|
||||||
|
] {
|
||||||
|
let out = container_cert_name(input);
|
||||||
|
assert!(
|
||||||
|
out.ends_with(".crt"),
|
||||||
|
"{:?} produced {:?}, which update-ca-certificates would ignore",
|
||||||
|
input,
|
||||||
|
out
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
|
||||||
|
"{:?} produced {:?}, which is not shell-safe",
|
||||||
|
input,
|
||||||
|
out
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fingerprint ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_configured_path_fingerprints_as_empty() {
|
||||||
|
assert_eq!(compute_ca_fingerprint(None), "");
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some("")), "");
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some(" ")), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_the_path_changes_the_fingerprint() {
|
||||||
|
let a = TempDir::new("path-a");
|
||||||
|
let b = TempDir::new("path-b");
|
||||||
|
// Identical *content* in both, so only the path differs.
|
||||||
|
a.write("corp.pem", "CERT-BODY");
|
||||||
|
b.write("corp.pem", "CERT-BODY");
|
||||||
|
|
||||||
|
let fp_a = compute_ca_fingerprint(Some(a.path().to_str().unwrap()));
|
||||||
|
let fp_b = compute_ca_fingerprint(Some(b.path().to_str().unwrap()));
|
||||||
|
assert_ne!(fp_a, "");
|
||||||
|
assert_ne!(
|
||||||
|
fp_a, fp_b,
|
||||||
|
"two different paths must not share a fingerprint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_the_certificate_content_at_the_same_path_changes_the_fingerprint() {
|
||||||
|
// The case a path-only fingerprint would miss: the corporate CA is
|
||||||
|
// rotated and the new one dropped in at exactly the same location.
|
||||||
|
let dir = TempDir::new("rotate");
|
||||||
|
dir.write("corp.pem", "OLD-CERT");
|
||||||
|
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
dir.write("corp.pem", "NEW-CERT");
|
||||||
|
let after = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
before, after,
|
||||||
|
"replacing the certificate at the same path must force a recreation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adding_or_removing_a_certificate_changes_the_fingerprint() {
|
||||||
|
let dir = TempDir::new("add");
|
||||||
|
dir.write("one.pem", "A");
|
||||||
|
let one = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
dir.write("two.pem", "B");
|
||||||
|
let two = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
assert_ne!(one, two);
|
||||||
|
fs::remove_file(dir.path().join("two.pem")).unwrap();
|
||||||
|
assert_eq!(compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), one);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_directory_fingerprints_identically() {
|
||||||
|
let dir = TempDir::new("stable");
|
||||||
|
dir.write("corp.pem", "SAME");
|
||||||
|
let a = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
let b = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
assert_eq!(a, b, "the fingerprint must not churn on repeated reads");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_path_fingerprints_differently_from_a_present_one() {
|
||||||
|
let dir = TempDir::new("missing");
|
||||||
|
let present = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
let missing =
|
||||||
|
compute_ca_fingerprint(Some(&format!("{}-gone", dir.path().to_str().unwrap())));
|
||||||
|
assert_ne!(present, missing);
|
||||||
|
assert_ne!(missing, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_certificate_files_in_the_directory_are_ignored() {
|
||||||
|
let dir = TempDir::new("noise");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
|
||||||
|
dir.write("README.md", "hello");
|
||||||
|
dir.write("openssl.cnf", "[req]");
|
||||||
|
assert_eq!(
|
||||||
|
compute_ca_fingerprint(Some(dir.path().to_str().unwrap())),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_path_resolves_to_nothing() {
|
||||||
|
assert_eq!(resolve(None).unwrap(), None);
|
||||||
|
assert_eq!(resolve(Some(" ")).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_path_is_an_actionable_error() {
|
||||||
|
let err = resolve(Some("/definitely/not/here/corp.pem")).unwrap_err();
|
||||||
|
assert!(err.contains("/definitely/not/here/corp.pem"), "{}", err);
|
||||||
|
assert!(err.contains("Settings"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_directory_is_an_actionable_error() {
|
||||||
|
let dir = TempDir::new("empty");
|
||||||
|
let err = resolve(Some(dir.path().to_str().unwrap())).unwrap_err();
|
||||||
|
assert!(err.contains("no certificate files"), "{}", err);
|
||||||
|
assert!(err.contains(".pem"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_mounts_at_the_shared_mount_point() {
|
||||||
|
let dir = TempDir::new("dir");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert!(resolved.is_dir);
|
||||||
|
assert_eq!(resolved.mount_target, CA_MOUNT_DIR);
|
||||||
|
assert_eq!(resolved.cert_files.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_file_mounts_under_the_mount_point_with_a_crt_name() {
|
||||||
|
// Mounting a file *at* /tmp/.host-ca would leave the entrypoint with no
|
||||||
|
// name to work from, and would make the mount point a file rather than
|
||||||
|
// the directory the entrypoint expects.
|
||||||
|
let dir = TempDir::new("file");
|
||||||
|
let file = dir.write("corp root.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert!(!resolved.is_dir);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.mount_target,
|
||||||
|
format!("{}/corp_root.crt", CA_MOUNT_DIR)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_is_accepted_whatever_its_extension() {
|
||||||
|
// The user pointed at it explicitly; don't second-guess.
|
||||||
|
let dir = TempDir::new("odd-ext");
|
||||||
|
let file = dir.write("corp.txt", "CERT");
|
||||||
|
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
|
||||||
|
assert_eq!(resolved.cert_files, vec![file]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── env vars ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_ca_points_every_consumer_at_the_bundle() {
|
||||||
|
let dir = TempDir::new("env");
|
||||||
|
dir.write("corp.pem", "CERT");
|
||||||
|
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap();
|
||||||
|
let vars = ca_env_vars(resolved.as_ref());
|
||||||
|
assert_eq!(
|
||||||
|
vars,
|
||||||
|
vec![
|
||||||
|
(NODE_EXTRA_CA_CERTS, CA_BUNDLE_PATH.to_string()),
|
||||||
|
(REQUESTS_CA_BUNDLE, CA_BUNDLE_PATH.to_string()),
|
||||||
|
(SSL_CERT_FILE, CA_BUNDLE_PATH.to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ca_clears_every_var_rather_than_omitting_it() {
|
||||||
|
// Omitting them would let a value baked into the project's snapshot
|
||||||
|
// image survive the setting being turned off.
|
||||||
|
let vars = ca_env_vars(None);
|
||||||
|
assert_eq!(vars.len(), CA_ENV_KEYS.len());
|
||||||
|
assert!(vars.iter().all(|(_, v)| v.is_empty()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,94 @@
|
|||||||
use bollard::container::UploadToContainerOptions;
|
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||||
|
/// attached — the raw duplex halves, before any policy about what to do with
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// This is the single place in the codebase that knows how to open an attached
|
||||||
|
/// exec. Both consumers are built on it:
|
||||||
|
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||||
|
/// which pump bytes through mpsc channels and a callback.
|
||||||
|
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||||
|
/// straight between a host TCP socket and these halves.
|
||||||
|
///
|
||||||
|
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||||
|
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||||
|
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||||
|
/// spliced into the proxied byte stream.
|
||||||
|
pub struct AttachedExec {
|
||||||
|
pub exec_id: String,
|
||||||
|
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||||
|
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||||
|
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||||
|
/// this app opens.
|
||||||
|
pub async fn create_attached_exec(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||||
|
///
|
||||||
|
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||||
|
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||||
|
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||||
|
/// stays the single place an attached exec is opened.
|
||||||
|
pub async fn create_attached_exec_as(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
user: &str,
|
||||||
|
working_dir: &str,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdin: Some(true),
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
tty: Some(tty),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some(user.to_string()),
|
||||||
|
working_dir: Some(working_dir.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let exec_id = exec.id.clone();
|
||||||
|
|
||||||
|
match docker
|
||||||
|
.start_exec(&exec_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?
|
||||||
|
{
|
||||||
|
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
}),
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
@@ -80,82 +161,55 @@ impl ExecSessionManager {
|
|||||||
where
|
where
|
||||||
F: Fn(Vec<u8>) + Send + 'static,
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
{
|
{
|
||||||
let docker = get_docker()?;
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
let exec = docker
|
mut output,
|
||||||
.create_exec(
|
mut input,
|
||||||
container_id,
|
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||||
CreateExecOptions {
|
|
||||||
attach_stdin: Some(true),
|
|
||||||
attach_stdout: Some(true),
|
|
||||||
attach_stderr: Some(true),
|
|
||||||
tty: Some(tty),
|
|
||||||
cmd: Some(cmd),
|
|
||||||
user: Some("claude".to_string()),
|
|
||||||
working_dir: Some("/workspace".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
|
||||||
|
|
||||||
let exec_id = exec.id.clone();
|
|
||||||
|
|
||||||
let result = docker
|
|
||||||
.start_exec(&exec_id, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
|
||||||
|
|
||||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
match result {
|
// Output reader task
|
||||||
StartExecResults::Attached { mut output, mut input } => {
|
let session_id_clone = session_id.to_string();
|
||||||
// Output reader task
|
let shutdown_tx_clone = shutdown_tx.clone();
|
||||||
let session_id_clone = session_id.to_string();
|
tokio::spawn(async move {
|
||||||
let shutdown_tx_clone = shutdown_tx.clone();
|
loop {
|
||||||
tokio::spawn(async move {
|
tokio::select! {
|
||||||
loop {
|
msg = output.next() => {
|
||||||
tokio::select! {
|
match msg {
|
||||||
msg = output.next() => {
|
Some(Ok(output)) => {
|
||||||
match msg {
|
on_output(output.into_bytes().to_vec());
|
||||||
Some(Ok(output)) => {
|
|
||||||
on_output(output.into_bytes().to_vec());
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ = shutdown_rx.recv() => {
|
Some(Err(e)) => {
|
||||||
log::info!("Exec session {} shutting down", session_id_clone);
|
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on_exit();
|
_ = shutdown_rx.recv() => {
|
||||||
let _ = shutdown_tx_clone;
|
log::info!("Exec session {} shutting down", session_id_clone);
|
||||||
});
|
break;
|
||||||
|
|
||||||
// Input writer task
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(data) = input_rx.recv().await {
|
|
||||||
if let Err(e) = input.write_all(&data).await {
|
|
||||||
log::error!("Failed to write to exec stdin: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
StartExecResults::Detached => {
|
on_exit();
|
||||||
return Err("Exec started in detached mode".to_string());
|
let _ = shutdown_tx_clone;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Input writer task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(data) = input_rx.recv().await {
|
||||||
|
if let Err(e) = input.write_all(&data).await {
|
||||||
|
log::error!("Failed to write to exec stdin: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
@@ -333,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
|
||||||
@@ -362,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()?;
|
||||||
|
|
||||||
@@ -373,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()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -390,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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! One-release migration shim for the removed built-in MCP feature.
|
||||||
|
//!
|
||||||
|
//! Older releases created a per-project user-defined bridge network
|
||||||
|
//! (`triple-c-net-<projectId>`) plus one container per Docker-backed MCP
|
||||||
|
//! server, and attached the project container to that network. Now that MCP
|
||||||
|
//! support is gone, those leftovers have to be torn down — a container whose
|
||||||
|
//! `NetworkMode` names a network that no longer exists refuses to start, so
|
||||||
|
//! the cleanup is paired with a forced container recreation (see
|
||||||
|
//! `container_needs_recreation`).
|
||||||
|
//!
|
||||||
|
//! Everything here is best-effort: failures are logged and never abort the
|
||||||
|
//! caller, and absent resources are a silent no-op. This module can be deleted
|
||||||
|
//! a release after all users have migrated.
|
||||||
|
|
||||||
|
use bollard::container::{ListContainersOptions, RemoveContainerOptions};
|
||||||
|
use bollard::network::InspectNetworkOptions;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// Network name used by the old MCP implementation for a project.
|
||||||
|
fn legacy_network_name(project_id: &str) -> String {
|
||||||
|
format!("triple-c-net-{}", project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-remove every leftover MCP server container.
|
||||||
|
///
|
||||||
|
/// Matched by the `triple-c.mcp-server` label rather than by name, so
|
||||||
|
/// containers survive even if the MCP server definitions they came from are
|
||||||
|
/// already gone from storage. Best-effort: errors are logged and skipped.
|
||||||
|
pub async fn remove_legacy_mcp_containers(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy MCP container cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"label".to_string(),
|
||||||
|
vec!["triple-c.mcp-server".to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = match docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to list legacy MCP containers: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
let Some(id) = container.id else { continue };
|
||||||
|
match docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::info!("Removed legacy MCP container {}", id),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the old per-project Docker network, disconnecting any remaining
|
||||||
|
/// members first (a network with attached endpoints cannot be deleted).
|
||||||
|
///
|
||||||
|
/// Silent no-op when the network does not exist. Best-effort: errors are
|
||||||
|
/// logged and never propagated.
|
||||||
|
pub async fn remove_legacy_project_network(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy network cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let network_name = legacy_network_name(project_id);
|
||||||
|
|
||||||
|
// Inspect to discover connected containers; absence means nothing to do.
|
||||||
|
let info = match docker
|
||||||
|
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("Legacy network {} not present, nothing to do", network_name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(containers) = info.containers {
|
||||||
|
for container_id in containers.into_keys() {
|
||||||
|
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
|
||||||
|
container: container_id.clone(),
|
||||||
|
force: true,
|
||||||
|
};
|
||||||
|
if let Err(e) = docker
|
||||||
|
.disconnect_network(&network_name, disconnect_opts)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to disconnect container {} from legacy network {}: {}",
|
||||||
|
container_id,
|
||||||
|
network_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match docker.remove_network(&network_name).await {
|
||||||
|
Ok(_) => log::info!("Removed legacy Docker network {}", network_name),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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 network;
|
pub mod gateway;
|
||||||
|
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)]
|
||||||
@@ -16,4 +21,9 @@ pub use image::*;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use network::*;
|
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,129 +0,0 @@
|
|||||||
use bollard::network::{CreateNetworkOptions, InspectNetworkOptions};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::client::get_docker;
|
|
||||||
|
|
||||||
/// Network name for a project's MCP containers.
|
|
||||||
fn project_network_name(project_id: &str) -> String {
|
|
||||||
format!("triple-c-net-{}", project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure a Docker bridge network exists for the project.
|
|
||||||
/// Returns the network name.
|
|
||||||
pub async fn ensure_project_network(project_id: &str) -> Result<String, String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
let network_name = project_network_name(project_id);
|
|
||||||
|
|
||||||
// Check if network already exists
|
|
||||||
match docker
|
|
||||||
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
log::debug!("Network {} already exists", network_name);
|
|
||||||
return Ok(network_name);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Network doesn't exist, create it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = CreateNetworkOptions {
|
|
||||||
name: network_name.clone(),
|
|
||||||
driver: "bridge".to_string(),
|
|
||||||
labels: HashMap::from([
|
|
||||||
("triple-c.managed".to_string(), "true".to_string()),
|
|
||||||
("triple-c.project-id".to_string(), project_id.to_string()),
|
|
||||||
]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.create_network(options)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to create network {}: {}", network_name, e))?;
|
|
||||||
|
|
||||||
log::info!("Created Docker network {}", network_name);
|
|
||||||
Ok(network_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect a container to the project network.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn connect_container_to_network(
|
|
||||||
container_id: &str,
|
|
||||||
network_name: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
|
|
||||||
let config = bollard::network::ConnectNetworkOptions {
|
|
||||||
container: container_id.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.connect_network(network_name, config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"Failed to connect container {} to network {}: {}",
|
|
||||||
container_id, network_name, e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Connected container {} to network {}",
|
|
||||||
container_id,
|
|
||||||
network_name
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the project network (best-effort). Disconnects all containers first.
|
|
||||||
pub async fn remove_project_network(project_id: &str) -> Result<(), String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
let network_name = project_network_name(project_id);
|
|
||||||
|
|
||||||
// Inspect to get connected containers
|
|
||||||
let info = match docker
|
|
||||||
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(info) => info,
|
|
||||||
Err(_) => {
|
|
||||||
log::debug!(
|
|
||||||
"Network {} not found, nothing to remove",
|
|
||||||
network_name
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disconnect all containers
|
|
||||||
if let Some(containers) = info.containers {
|
|
||||||
for (container_id, _) in containers {
|
|
||||||
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
|
|
||||||
container: container_id.clone(),
|
|
||||||
force: true,
|
|
||||||
};
|
|
||||||
if let Err(e) = docker
|
|
||||||
.disconnect_network(&network_name, disconnect_opts)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!(
|
|
||||||
"Failed to disconnect container {} from network {}: {}",
|
|
||||||
container_id,
|
|
||||||
network_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the network
|
|
||||||
match docker.remove_network(&network_name).await {
|
|
||||||
Ok(_) => log::info!("Removed Docker network {}", network_name),
|
|
||||||
Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod auth_bridge;
|
||||||
|
mod browser_view;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
mod install_helper;
|
mod install_helper;
|
||||||
@@ -6,21 +8,180 @@ 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 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 storage::mcp_store::McpStore;
|
use tauri::async_runtime::JoinHandle;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::sync::watch;
|
||||||
use web_terminal::WebTerminalServer;
|
use web_terminal::WebTerminalServer;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: Arc<ProjectsStore>,
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
pub settings_store: Arc<SettingsStore>,
|
pub settings_store: Arc<SettingsStore>,
|
||||||
pub mcp_store: Arc<McpStore>,
|
|
||||||
pub exec_manager: Arc<ExecSessionManager>,
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
|
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() {
|
||||||
@@ -40,19 +201,15 @@ pub fn run() {
|
|||||||
panic!("Failed to initialize settings store: {}", e);
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let mcp_store = Arc::new(match McpStore::new() {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to initialize MCP store: {}", e);
|
|
||||||
panic!("Failed to initialize MCP store: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
let exec_manager = Arc::new(ExecSessionManager::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())
|
||||||
@@ -61,9 +218,10 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
mcp_store,
|
|
||||||
exec_manager,
|
exec_manager,
|
||||||
|
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")) {
|
||||||
@@ -88,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,
|
||||||
@@ -100,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);
|
||||||
@@ -109,43 +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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -165,11 +421,43 @@ 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
|
||||||
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||||
|
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
|
||||||
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
|
commands::auth_token_commands::cancel_claude_token,
|
||||||
|
commands::auth_token_commands::has_claude_token,
|
||||||
|
commands::auth_token_commands::clear_claude_token,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
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
|
||||||
@@ -187,11 +475,6 @@ pub fn run() {
|
|||||||
commands::file_commands::download_container_file,
|
commands::file_commands::download_container_file,
|
||||||
commands::file_commands::download_container_backup,
|
commands::file_commands::download_container_backup,
|
||||||
commands::file_commands::upload_file_to_container,
|
commands::file_commands::upload_file_to_container,
|
||||||
// MCP
|
|
||||||
commands::mcp_commands::list_mcp_servers,
|
|
||||||
commands::mcp_commands::add_mcp_server,
|
|
||||||
commands::mcp_commands::update_mcp_server,
|
|
||||||
commands::mcp_commands::remove_mcp_server,
|
|
||||||
// AWS
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// Updates
|
// Updates
|
||||||
@@ -215,7 +498,158 @@ 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)
|
||||||
|
commands::inspect_commands::list_claude_sessions,
|
||||||
|
commands::inspect_commands::resume_session_command,
|
||||||
|
commands::inspect_commands::list_container_capabilities,
|
||||||
|
commands::inspect_commands::list_scheduled_tasks,
|
||||||
|
commands::inspect_commands::add_scheduled_task,
|
||||||
|
commands::inspect_commands::update_scheduled_task,
|
||||||
|
commands::inspect_commands::get_scheduled_task_log,
|
||||||
|
commands::inspect_commands::set_scheduled_task_enabled,
|
||||||
|
commands::inspect_commands::run_scheduled_task_now,
|
||||||
|
commands::inspect_commands::remove_scheduled_task,
|
||||||
|
commands::inspect_commands::get_scheduler_notifications,
|
||||||
|
commands::inspect_commands::clear_scheduler_notifications,
|
||||||
])
|
])
|
||||||
.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,
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum McpTransportType {
|
|
||||||
Stdio,
|
|
||||||
#[serde(alias = "sse")]
|
|
||||||
Http,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for McpTransportType {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Stdio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServer {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub transport_type: McpTransportType,
|
|
||||||
pub command: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub args: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub env: HashMap<String, String>,
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub headers: HashMap<String, String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub docker_image: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub container_port: Option<u16>,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpServer {
|
|
||||||
pub fn new(name: String) -> Self {
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
Self {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
name,
|
|
||||||
transport_type: McpTransportType::default(),
|
|
||||||
command: None,
|
|
||||||
args: Vec::new(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
url: None,
|
|
||||||
headers: HashMap::new(),
|
|
||||||
docker_image: None,
|
|
||||||
container_port: None,
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_docker(&self) -> bool {
|
|
||||||
self.docker_image.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mcp_container_name(&self) -> String {
|
|
||||||
format!("triple-c-mcp-{}", self.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn effective_container_port(&self) -> u16 {
|
|
||||||
self.container_port.unwrap_or(3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,11 +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 mod mcp_server;
|
|
||||||
|
|
||||||
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::*;
|
||||||
pub use mcp_server::*;
|
|
||||||
|
|||||||
@@ -30,6 +30,58 @@ fn default_full_permissions() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `use_shared_auth_token` defaults to **on**: once the user has run
|
||||||
|
/// `claude setup-token` once, every existing Anthropic-backend project should
|
||||||
|
/// pick the token up without being edited one by one. Projects deliberately
|
||||||
|
/// pinned to their own `claude login` identity opt out.
|
||||||
|
fn default_use_shared_auth_token() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much autonomy Claude Code is granted inside the container.
|
||||||
|
///
|
||||||
|
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||||
|
/// the single definition of that mapping and must be used by every call site.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum PermissionMode {
|
||||||
|
/// Read-only planning mode.
|
||||||
|
Plan,
|
||||||
|
/// Claude Code's own default behavior (prompts for permission).
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
/// Auto-accept file edits, prompt for everything else.
|
||||||
|
AcceptEdits,
|
||||||
|
/// Skip all permission prompts.
|
||||||
|
Bypass,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionMode {
|
||||||
|
/// The CLI flags this mode adds to a `claude` invocation.
|
||||||
|
/// Defined once here so every call site stays in sync.
|
||||||
|
pub fn cli_args(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()],
|
||||||
|
PermissionMode::Default => Vec::new(),
|
||||||
|
PermissionMode::AcceptEdits => {
|
||||||
|
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
|
||||||
|
}
|
||||||
|
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var.
|
||||||
|
/// Matches the serde `camelCase` representation.
|
||||||
|
pub fn as_env_value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => "plan",
|
||||||
|
PermissionMode::Default => "default",
|
||||||
|
PermissionMode::AcceptEdits => "acceptEdits",
|
||||||
|
PermissionMode::Bypass => "bypass",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Settings for Claude Code CLI behavior inside the container.
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -71,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,
|
||||||
@@ -78,9 +132,47 @@ pub struct Project {
|
|||||||
pub sandbox_mode_enabled: bool,
|
pub sandbox_mode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
pub mission_control_enabled: bool,
|
||||||
|
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||||
|
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||||
|
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||||
|
/// Purely host-side — it deliberately has no container-recreation label,
|
||||||
|
/// because toggling it changes nothing about the container itself.
|
||||||
|
#[serde(default)]
|
||||||
|
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,
|
||||||
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
|
/// [`Backend::Anthropic`] and a token has actually been stored.
|
||||||
|
///
|
||||||
|
/// Defaults to **true** so a single `setup-token` run covers every project;
|
||||||
|
/// turn it off to pin a project to the identity it logged in with inside
|
||||||
|
/// its own container.
|
||||||
|
#[serde(default = "default_use_shared_auth_token")]
|
||||||
|
pub use_shared_auth_token: bool,
|
||||||
|
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
||||||
|
/// because it is the value already stored in users' `projects.json`; it is
|
||||||
|
/// the fallback in `effective_permission_mode()` so old projects keep
|
||||||
|
/// behaving identically without a data migration.
|
||||||
#[serde(default = "default_full_permissions")]
|
#[serde(default = "default_full_permissions")]
|
||||||
pub full_permissions: bool,
|
pub full_permissions: bool,
|
||||||
|
/// Per-project permission mode. `None` means "not set yet" → fall back to
|
||||||
|
/// the legacy `full_permissions` flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission_mode: Option<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
|
/// 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>,
|
||||||
@@ -92,8 +184,6 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled_mcp_servers: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
/// User-defined display names for terminal tabs, keyed by session id.
|
/// User-defined display names for terminal tabs, keyed by session id.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -116,8 +206,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.
|
||||||
@@ -125,6 +217,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,
|
||||||
}
|
}
|
||||||
@@ -135,6 +231,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")]
|
||||||
@@ -173,27 +291,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 {
|
||||||
@@ -208,19 +359,24 @@ 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,
|
||||||
|
browser_view_enabled: false,
|
||||||
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
|
ca_cert_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
custom_env_vars: Vec::new(),
|
custom_env_vars: Vec::new(),
|
||||||
port_mappings: Vec::new(),
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
enabled_mcp_servers: Vec::new(),
|
|
||||||
claude_code_settings: None,
|
claude_code_settings: None,
|
||||||
renamed_session_names: HashMap::new(),
|
renamed_session_names: HashMap::new(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
@@ -228,6 +384,17 @@ impl Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The permission mode to actually use for this project.
|
||||||
|
/// Falls back to the legacy `full_permissions` boolean when the newer
|
||||||
|
/// `permission_mode` field has never been set.
|
||||||
|
pub fn effective_permission_mode(&self) -> PermissionMode {
|
||||||
|
self.permission_mode.unwrap_or(if self.full_permissions {
|
||||||
|
PermissionMode::Bypass
|
||||||
|
} else {
|
||||||
|
PermissionMode::Default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::models::McpServer;
|
|
||||||
|
|
||||||
pub struct McpStore {
|
|
||||||
servers: Mutex<Vec<McpServer>>,
|
|
||||||
file_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpStore {
|
|
||||||
pub fn new() -> Result<Self, String> {
|
|
||||||
let data_dir = dirs::data_dir()
|
|
||||||
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
|
||||||
.join("triple-c");
|
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
|
||||||
|
|
||||||
let file_path = data_dir.join("mcp_servers.json");
|
|
||||||
|
|
||||||
let servers = if file_path.exists() {
|
|
||||||
match fs::read_to_string(&file_path) {
|
|
||||||
Ok(data) => {
|
|
||||||
match serde_json::from_str::<Vec<McpServer>>(&data) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e);
|
|
||||||
let backup = file_path.with_extension("json.bak");
|
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted mcp_servers.json: {}", be);
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to read mcp_servers.json: {}", e);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
servers: Mutex::new(servers),
|
|
||||||
file_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<McpServer>> {
|
|
||||||
self.servers.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save(&self, servers: &[McpServer]) -> Result<(), String> {
|
|
||||||
let data = serde_json::to_string_pretty(servers)
|
|
||||||
.map_err(|e| format!("Failed to serialize MCP servers: {}", e))?;
|
|
||||||
|
|
||||||
// Atomic write: write to temp file, then rename
|
|
||||||
let tmp_path = self.file_path.with_extension("json.tmp");
|
|
||||||
fs::write(&tmp_path, data)
|
|
||||||
.map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?;
|
|
||||||
fs::rename(&tmp_path, &self.file_path)
|
|
||||||
.map_err(|e| format!("Failed to rename MCP servers file: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<McpServer> {
|
|
||||||
self.lock().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<McpServer> {
|
|
||||||
self.lock().iter().find(|s| s.id == id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
let cloned = server.clone();
|
|
||||||
servers.push(server);
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(cloned)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) {
|
|
||||||
*s = updated.clone();
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(updated)
|
|
||||||
} else {
|
|
||||||
Err(format!("MCP server {} not found", updated.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&self, id: &str) -> Result<(), String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
let initial_len = servers.len();
|
|
||||||
servers.retain(|s| s.id != id);
|
|
||||||
if servers.len() == initial_len {
|
|
||||||
return Err(format!("MCP server {} not found", id));
|
|
||||||
}
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
|
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;
|
||||||
pub mod mcp_store;
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use projects_store::*;
|
pub use projects_store::*;
|
||||||
@@ -9,5 +9,3 @@ pub use projects_store::*;
|
|||||||
pub use secure::*;
|
pub use secure::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use settings_store::*;
|
pub use settings_store::*;
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use mcp_store::*;
|
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ impl ProjectsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Granular setter for the auth bridge opt-in, so toggling it can't clobber
|
||||||
|
/// concurrent edits to the rest of the project record.
|
||||||
|
pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||||
|
let mut projects = self.lock();
|
||||||
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
p.auth_bridge_enabled = enabled;
|
||||||
|
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
self.save(&projects)?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Project {} not found", project_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||||
let mut projects = self.lock();
|
let mut projects = self.lock();
|
||||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
|
//! OS keychain access, via the `keyring` crate.
|
||||||
|
//!
|
||||||
|
//! Two kinds of secret live here:
|
||||||
|
//! * **per-project** secrets (git token, AWS keys, …), keyed by project id;
|
||||||
|
//! * the **shared Claude Code OAuth token**, which is global — one
|
||||||
|
//! `claude setup-token` run authenticates every Anthropic-backend project.
|
||||||
|
//!
|
||||||
|
//! Nothing in this module ever logs a secret or folds one into an error string.
|
||||||
|
|
||||||
|
/// Keychain service for the single, global Claude Code OAuth token minted by
|
||||||
|
/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||||
|
const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token";
|
||||||
|
|
||||||
|
/// Keychain service for the token's **rotation id** — a fresh random value
|
||||||
|
/// written every time the token is stored.
|
||||||
|
///
|
||||||
|
/// Container recreation is driven off Docker labels, which anything on the host
|
||||||
|
/// can read with `docker inspect`. The token itself must obviously not go in a
|
||||||
|
/// label, and neither should a bare hash of it: a hash is a verification oracle
|
||||||
|
/// (holding a candidate token, you could confirm it). This id is not derived
|
||||||
|
/// from the token at all — it is unrelated random data that merely *changes*
|
||||||
|
/// whenever the token does, which is exactly (and only) what change detection
|
||||||
|
/// needs.
|
||||||
|
const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version";
|
||||||
|
|
||||||
|
/// Fixed account name used for every triple-c keychain entry.
|
||||||
|
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
@@ -43,3 +71,201 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shared Claude Code OAuth token (global, not per project)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a single-value keychain entry. `Ok(None)` when the entry is absent.
|
||||||
|
/// The error text names the entry, never its value.
|
||||||
|
fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("Failed to retrieve {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a keychain entry, treating "wasn't there" as success.
|
||||||
|
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the shared Claude Code OAuth token, replacing any previous one, and
|
||||||
|
/// mint a fresh rotation id so containers holding the old token are flagged for
|
||||||
|
/// recreation. Blank input is rejected rather than silently stored.
|
||||||
|
pub fn store_claude_oauth_token(token: &str) -> Result<(), String> {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty Claude authentication token.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(token)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?;
|
||||||
|
|
||||||
|
// Rotation id second: if this fails the token is still usable, and the
|
||||||
|
// stale id only costs one extra container recreation later.
|
||||||
|
let version = uuid::Uuid::new_v4().to_string();
|
||||||
|
let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
version_entry
|
||||||
|
.set_password(&version)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the shared Claude Code OAuth token, if one has been stored.
|
||||||
|
pub fn get_claude_oauth_token() -> Result<Option<String>, String> {
|
||||||
|
read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rotation id of the currently stored token. Opaque random data — safe to
|
||||||
|
/// put in a Docker label, unlike the token or any hash of it.
|
||||||
|
pub fn get_claude_oauth_token_version() -> Result<Option<String>, String> {
|
||||||
|
read_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shared Claude Code OAuth token is currently stored. A keychain
|
||||||
|
/// failure is reported as "no token" rather than surfacing as an error, so the
|
||||||
|
/// UI degrades to the un-authenticated state instead of breaking.
|
||||||
|
pub fn has_claude_oauth_token() -> bool {
|
||||||
|
matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the shared Claude Code OAuth token and its rotation id. Both are
|
||||||
|
/// attempted even if the first fails, so a partial failure cannot strand the
|
||||||
|
/// token behind a deleted id.
|
||||||
|
pub fn delete_claude_oauth_token() -> Result<(), String> {
|
||||||
|
let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token");
|
||||||
|
let version_result = delete_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
);
|
||||||
|
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';
|
||||||
|
|||||||
@@ -205,11 +205,11 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
let mut cmd = vec!["claude".to_string()];
|
||||||
if project.full_permissions {
|
cmd.extend(permission_args);
|
||||||
cmd.push("--dangerously-skip-permissions".to_string());
|
|
||||||
}
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +218,13 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let claude_cmd = if project.full_permissions {
|
// The args are interpolated into a shell script string below, so
|
||||||
"exec claude --dangerously-skip-permissions"
|
// single-quote each one.
|
||||||
} else {
|
let permission_flags: String = permission_args
|
||||||
"exec claude"
|
.iter()
|
||||||
};
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -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,30 +1,63 @@
|
|||||||
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";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
|
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||||
|
import ToastHost from "./components/ui/ToastHost";
|
||||||
|
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||||
|
import Button from "./components/ui/Button";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useTerminal } from "./hooks/useTerminal";
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
import { useSTT } from "./hooks/useSTT";
|
import { useSTT } from "./hooks/useSTT";
|
||||||
import { useAppState } from "./store/appState";
|
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||||
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
|
||||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
useAppState(
|
||||||
);
|
useShallow(s => ({
|
||||||
|
sessions: s.sessions,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setSttToggle: s.setSttToggle,
|
||||||
|
}))
|
||||||
|
);
|
||||||
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
|
||||||
@@ -35,28 +68,22 @@ export default function App() {
|
|||||||
setSttToggle(stt.toggle);
|
setSttToggle(stt.toggle);
|
||||||
}, [stt.toggle, setSttToggle]);
|
}, [stt.toggle, setSttToggle]);
|
||||||
|
|
||||||
|
useContainerProgress();
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
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();
|
||||||
refreshMcp();
|
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
loadVersion();
|
loadVersion();
|
||||||
@@ -72,16 +99,42 @@ 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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-6 gap-4 bg-[var(--bg-primary)]">
|
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex flex-1 min-h-0 gap-4">
|
<div className="flex flex-1 min-h-0 gap-3">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
|
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
|
||||||
{sessions.length === 0 ? (
|
{tabOrder.length === 0 ? (
|
||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
{homeProjectIds.map((projectId) => (
|
||||||
|
<ProjectHome
|
||||||
|
key={projectId}
|
||||||
|
projectId={projectId}
|
||||||
|
active={activeTabKey === homeTabKey(projectId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<TerminalView
|
<TerminalView
|
||||||
key={session.id}
|
key={session.id}
|
||||||
@@ -94,25 +147,119 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar stt={stt} />
|
<StatusBar stt={stt} />
|
||||||
|
<ToastHost />
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First run is a checklist, not a paragraph: it reuses state the app already
|
||||||
|
* tracks and ends in a real button.
|
||||||
|
*/
|
||||||
function WelcomeScreen() {
|
function WelcomeScreen() {
|
||||||
|
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
projects: s.projects,
|
||||||
|
openProjectHome: s.openProjectHome,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const steps: {
|
||||||
|
label: string;
|
||||||
|
state: boolean | null;
|
||||||
|
pendingLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: "Docker detected",
|
||||||
|
state: dockerAvailable,
|
||||||
|
pendingLabel: "Checking for Docker…",
|
||||||
|
failLabel: "Docker not available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Container image ready",
|
||||||
|
state: imageExists,
|
||||||
|
pendingLabel: "Checking for the image…",
|
||||||
|
failLabel: "Image not pulled yet — see Settings › Container",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
|
||||||
|
state: projects.length > 0 ? true : false,
|
||||||
|
pendingLabel: "",
|
||||||
|
failLabel: "No projects yet",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<div className="text-center">
|
<div className="w-full max-w-md">
|
||||||
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
|
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
|
||||||
Triple-C
|
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
|
||||||
</h1>
|
Claude Code, sandboxed in a container.
|
||||||
<p className="text-sm mb-4">Claude Code Container</p>
|
|
||||||
<p className="text-xs max-w-md">
|
|
||||||
Add a project from the sidebar, start its container, then open a
|
|
||||||
terminal to begin using Claude Code in a sandboxed environment.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-2 mb-5">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<li
|
||||||
|
key={step.label}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<StatusIndicator
|
||||||
|
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
|
||||||
|
label={
|
||||||
|
step.state === true
|
||||||
|
? step.label
|
||||||
|
: step.state === false
|
||||||
|
? step.failLabel
|
||||||
|
: step.pendingLabel
|
||||||
|
}
|
||||||
|
className="text-[13px]"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
|
||||||
|
{projects.length === 0 ? "Add your first project" : "Add a project"}
|
||||||
|
</Button>
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
|
||||||
|
Open {projects[0].name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-[var(--text-secondary)]">
|
||||||
|
Then start its container and press{" "}
|
||||||
|
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
|
||||||
|
Ctrl+T
|
||||||
|
</kbd>{" "}
|
||||||
|
to open a Claude terminal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
import { useDocker } from "../hooks/useDocker";
|
import { useDocker } from "../hooks/useDocker";
|
||||||
|
import Modal from "./ui/Modal";
|
||||||
|
import Button from "./ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -16,27 +18,11 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
const [phase, setPhase] = useState<Phase>("idle");
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
const [log, setLog] = useState<string[]>([]);
|
const [log, setLog] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [loadOptions]);
|
}, [loadOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && phase !== "installing") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose, phase]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current && phase !== "installing") onClose();
|
|
||||||
},
|
|
||||||
[onClose, phase],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
setPhase("installing");
|
setPhase("installing");
|
||||||
setLog([]);
|
setLog([]);
|
||||||
@@ -70,142 +56,122 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
const installVerb =
|
||||||
|
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Docker not detected"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[34rem]"
|
||||||
|
// Closing mid-install would orphan a privileged installer.
|
||||||
|
dismissible={phase !== "installing"}
|
||||||
|
footer={
|
||||||
|
phase === "idle" ? (
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
|
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||||
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
|
Triple-C needs a Docker-compatible runtime to manage sandboxed project
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
containers. We can install{" "}
|
||||||
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
|
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
|
||||||
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
|
you, or you can follow the official instructions.
|
||||||
for you, or you can follow the official instructions.
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
{phase === "idle" && (
|
{phase === "idle" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{options.can_auto_install ? (
|
{options.can_auto_install ? (
|
||||||
<button
|
<Button size="md" variant="primary" onClick={handleInstall}>
|
||||||
onClick={handleInstall}
|
{installVerb} ({options.auto_install_method})
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
</Button>
|
||||||
>
|
) : (
|
||||||
{installVerb} ({options.auto_install_method})
|
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
</button>
|
One-click install unavailable:{" "}
|
||||||
) : (
|
<span className="text-[var(--text-primary)]">
|
||||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
{options.auto_install_blocker ?? "required tooling missing."}
|
||||||
One-click install unavailable:{" "}
|
</span>
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{options.auto_install_blocker ?? "required tooling missing."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowManual((s) => !s)}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleOpenDocs}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Open official documentation ↗
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "installing" && (
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">
|
|
||||||
Installing… a system password prompt may appear. Do not close this window.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "done" && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="text-sm text-[var(--success)]">Install finished.</div>
|
|
||||||
{options.post_install_notes.length > 0 && (
|
|
||||||
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
|
||||||
{options.post_install_notes.map((note, i) => (
|
|
||||||
<li key={i}>{note}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleRecheck}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Re-check Docker
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "error" && (
|
<Button size="md" onClick={() => setShowManual((s) => !s)}>
|
||||||
<div className="flex flex-col gap-2">
|
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||||
<div className="text-sm text-[var(--error)]">Install failed.</div>
|
</Button>
|
||||||
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPhase("idle")}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleOpenDocs}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Open official docs ↗
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(showManual || phase === "error") && (
|
<Button size="md" onClick={handleOpenDocs}>
|
||||||
<div className="mt-4">
|
Open official documentation ↗
|
||||||
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
</Button>
|
||||||
Manual install steps
|
</div>
|
||||||
</div>
|
)}
|
||||||
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
|
||||||
{options.manual_steps.map((step, i) => (
|
{phase === "installing" && (
|
||||||
<li key={i}>{step}</li>
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Installing… a system password prompt may appear. Do not close this window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "done" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-[13px] text-[var(--success)]">Install finished.</div>
|
||||||
|
{options.post_install_notes.length > 0 && (
|
||||||
|
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||||
|
{options.post_install_notes.map((note, i) => (
|
||||||
|
<li key={i}>{note}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button size="md" variant="primary" onClick={handleRecheck}>
|
||||||
|
Re-check Docker
|
||||||
|
</Button>
|
||||||
|
<Button size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{log.length > 0 && (
|
{phase === "error" && (
|
||||||
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
<div className="flex flex-col gap-2">
|
||||||
{log.map((line, i) => (
|
<div className="text-[13px] text-[var(--error)]">Install failed.</div>
|
||||||
<div key={i}>{line}</div>
|
{error && (
|
||||||
|
<div className="text-xs font-mono text-[var(--error)] break-words">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button size="md" onClick={() => setPhase("idle")}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={handleOpenDocs}>
|
||||||
|
Open official docs ↗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showManual || phase === "error") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||||
|
Manual install steps
|
||||||
|
</div>
|
||||||
|
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
|
{options.manual_steps.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ol>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === "idle" && (
|
{log.length > 0 && (
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||||
<button
|
{log.map((line, i) => (
|
||||||
onClick={onClose}
|
<div key={i}>{line}</div>
|
||||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
))}
|
||||||
>
|
</div>
|
||||||
Dismiss
|
)}
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { getHelpContent } from "../../lib/tauri-commands";
|
import { getHelpContent } from "../../lib/tauri-commands";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -140,32 +142,16 @@ function renderMarkdown(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HelpDialog({ onClose }: Props) {
|
export default function HelpDialog({ onClose }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHelpContent()
|
getHelpContent()
|
||||||
.then(setMarkdown)
|
.then(setMarkdown)
|
||||||
.catch((e) => setError(String(e)));
|
.catch((e) => setError(String(e)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle anchor link clicks to scroll within the dialog
|
// Handle anchor link clicks to scroll within the dialog
|
||||||
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="How to Use Triple-C"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[48rem]"
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
<div ref={contentRef} onClick={handleContentClick} className="help-content">
|
||||||
{/* Header */}
|
{error && (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
<p className="text-[var(--error)] text-sm">
|
||||||
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
Failed to load help content: {error}
|
||||||
<button
|
</p>
|
||||||
onClick={onClose}
|
)}
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
{!markdown && !error && (
|
||||||
>
|
<p className="text-[var(--text-secondary)] text-sm">Loading…</p>
|
||||||
Close
|
)}
|
||||||
</button>
|
{markdown && (
|
||||||
</div>
|
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||||
|
)}
|
||||||
{/* Scrollable content */}
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
onClick={handleContentClick}
|
|
||||||
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
|
|
||||||
)}
|
|
||||||
{!markdown && !error && (
|
|
||||||
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
|
|
||||||
)}
|
|
||||||
{markdown && (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import {
|
||||||
|
useAppState,
|
||||||
|
isHomeTab,
|
||||||
|
tabKeyId,
|
||||||
|
terminalTabKey,
|
||||||
|
} from "../../store/appState";
|
||||||
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
sessionId: string;
|
||||||
|
x: 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 }> = {
|
||||||
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||||
|
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
|
* 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() {
|
||||||
|
const { sessions, close } = useTerminal();
|
||||||
|
const { projects, update } = useProjects();
|
||||||
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
moveTab: s.moveTab,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
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(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const dismiss = () => setMenu(null);
|
||||||
|
window.addEventListener("click", dismiss);
|
||||||
|
window.addEventListener("scroll", dismiss, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", dismiss);
|
||||||
|
window.removeEventListener("scroll", dismiss, true);
|
||||||
|
};
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingId) {
|
||||||
|
renameInputRef.current?.focus();
|
||||||
|
renameInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [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) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
|
No open tabs — select a project to open its home view.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
return project?.renamed_session_names?.[sessionId] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const current =
|
||||||
|
getCustomName(session.projectId, sessionId) ??
|
||||||
|
session.sessionName ??
|
||||||
|
session.projectName;
|
||||||
|
setRenameDraft(current);
|
||||||
|
setRenamingId(sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitRename = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = renameDraft.trim();
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (trimmed) {
|
||||||
|
map[sessionId] = trimmed;
|
||||||
|
} else {
|
||||||
|
delete map[sessionId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename terminal tab:", err);
|
||||||
|
} finally {
|
||||||
|
setRenamingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCustomName = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) return;
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (!(sessionId in map)) {
|
||||||
|
setMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete map[sessionId];
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to reset terminal tab name:", err);
|
||||||
|
} finally {
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (active: boolean, dragging: boolean) =>
|
||||||
|
`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
|
||||||
|
? "bg-[var(--bg-primary)] 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 (
|
||||||
|
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
|
{tabOrder.map((key, index) => {
|
||||||
|
const tab = renderTab(key, index);
|
||||||
|
if (!tab) return null;
|
||||||
|
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||||
|
if (marker) markerPending = false;
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
{marker && dropMarker}
|
||||||
|
{tab}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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 && (() => {
|
||||||
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
|
const hasCustom = session
|
||||||
|
? !!getCustomName(session.projectId, menu.sessionId)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
|
||||||
|
style={{ top: menu.y, left: menu.x, boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => startRename(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Rename tab
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => clearCustomName(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Reset name
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
useAppState.getState().openProjectHome(session.projectId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open project home
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-[var(--border-color)] my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
close(menu.sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({
|
|||||||
vi.mock("../settings/SettingsPanel", () => ({
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("../mcp/McpPanel", () => ({
|
|
||||||
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,6 +34,12 @@ describe("Sidebar", () => {
|
|||||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the project list, not a settings form, in the projects view", () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByTestId("project-list")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("content area has min-w-0 to prevent flex overflow", () => {
|
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||||
const { container } = render(<Sidebar />);
|
const { container } = render(<Sidebar />);
|
||||||
const contentArea = container.querySelector(".overflow-y-auto");
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { ReactNode } from "react";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
import McpPanel from "../mcp/McpPanel";
|
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
type SidebarView = "projects" | "mcp" | "settings";
|
type SidebarView = "projects" | "settings";
|
||||||
|
|
||||||
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
||||||
{
|
{
|
||||||
@@ -17,18 +16,6 @@ const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
view: "mcp",
|
|
||||||
label: "MCP",
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M9 2v6" />
|
|
||||||
<path d="M15 2v6" />
|
|
||||||
<path d="M7 8h10v4a5 5 0 0 1-10 0V8z" />
|
|
||||||
<path d="M12 17v5" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
view: "settings",
|
view: "settings",
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@@ -76,7 +63,7 @@ export default function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebarCollapsed}
|
onClick={toggleSidebarCollapsed}
|
||||||
title="Expand sidebar"
|
title="Expand sidebar"
|
||||||
@@ -102,15 +89,12 @@ export default function Sidebar() {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
|
||||||
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -128,13 +112,7 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? (
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
<ProjectList />
|
|
||||||
) : sidebarView === "mcp" ? (
|
|
||||||
<McpPanel />
|
|
||||||
) : (
|
|
||||||
<SettingsPanel />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||||
<span>
|
<span>
|
||||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import MainTabs from "./MainTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import UpdateDialog from "../settings/UpdateDialog";
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||||
import HelpDialog from "./HelpDialog";
|
import HelpDialog from "./HelpDialog";
|
||||||
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||||
@@ -48,34 +49,48 @@ export default function TopBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
<div className="flex-1 overflow-x-auto pl-2">
|
<div className="flex-1 overflow-x-auto pl-1">
|
||||||
<TerminalTabs />
|
<MainTabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center gap-3 px-3 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowUpdateDialog(true)}
|
onClick={() => setShowUpdateDialog(true)}
|
||||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--accent-emphasis)] text-white hover:bg-[var(--accent-emphasis-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{imageUpdateInfo && (
|
{imageUpdateInfo && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowImageUpdateDialog(true)}
|
onClick={() => setShowImageUpdateDialog(true)}
|
||||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--warning-emphasis)] text-white hover:opacity-90 transition-colors"
|
||||||
title="A newer container image is available"
|
title="A newer container image is available"
|
||||||
>
|
>
|
||||||
Image Update
|
Image Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<HealthDot
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
state={dockerAvailable}
|
||||||
|
okLabel="Docker"
|
||||||
|
failLabel="Docker unavailable"
|
||||||
|
pendingLabel="Docker — checking"
|
||||||
|
/>
|
||||||
|
<HealthDot
|
||||||
|
state={imageExists}
|
||||||
|
okLabel="Image"
|
||||||
|
failLabel="Image missing"
|
||||||
|
pendingLabel="Image — checking"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowHelpDialog(true)}
|
onClick={() => setShowHelpDialog(true)}
|
||||||
title="Help"
|
title="Help"
|
||||||
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
aria-label="Help"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
@@ -103,15 +118,29 @@ export default function TopBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
|
/**
|
||||||
return (
|
* `null` (still checking) is visually distinct and pulses; `false` is an
|
||||||
<span className="flex items-center gap-1">
|
* outage and renders red — previously both fell through to the same gray dot.
|
||||||
<span
|
*/
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
function HealthDot({
|
||||||
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
|
state,
|
||||||
}`}
|
okLabel,
|
||||||
/>
|
failLabel,
|
||||||
{label}
|
pendingLabel,
|
||||||
</span>
|
}: {
|
||||||
);
|
state: boolean | null;
|
||||||
|
okLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
pendingLabel: string;
|
||||||
|
}) {
|
||||||
|
let tone: StatusTone = "unknown";
|
||||||
|
let label = pendingLabel;
|
||||||
|
if (state === true) {
|
||||||
|
tone = "ok";
|
||||||
|
label = okLabel;
|
||||||
|
} else if (state === false) {
|
||||||
|
tone = "error";
|
||||||
|
label = failLabel;
|
||||||
|
}
|
||||||
|
return <StatusIndicator tone={tone} label={label} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
||||||
import McpServerCard from "./McpServerCard";
|
|
||||||
|
|
||||||
export default function McpPanel() {
|
|
||||||
const { mcpServers, refresh, add, update, remove } = useMcpServers();
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await add(name);
|
|
||||||
setNewName("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 p-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
|
|
||||||
MCP Servers{" "}
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
|
||||||
Define MCP servers globally, then enable them per-project.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add new server */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
|
||||||
placeholder="Server name..."
|
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{mcpServers.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] italic">
|
|
||||||
No MCP servers configured.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
mcpServers.map((server) => (
|
|
||||||
<McpServerCard
|
|
||||||
key={server.id}
|
|
||||||
server={server}
|
|
||||||
onUpdate={update}
|
|
||||||
onRemove={remove}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { McpServer, McpTransportType } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
server: McpServer;
|
|
||||||
onUpdate: (server: McpServer) => Promise<McpServer | void>;
|
|
||||||
onRemove: (id: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [name, setName] = useState(server.name);
|
|
||||||
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
|
|
||||||
const [command, setCommand] = useState(server.command ?? "");
|
|
||||||
const [args, setArgs] = useState(server.args.join(" "));
|
|
||||||
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
|
|
||||||
const [url, setUrl] = useState(server.url ?? "");
|
|
||||||
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
|
|
||||||
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
|
|
||||||
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setName(server.name);
|
|
||||||
setTransportType(server.transport_type);
|
|
||||||
setCommand(server.command ?? "");
|
|
||||||
setArgs(server.args.join(" "));
|
|
||||||
setEnvPairs(Object.entries(server.env));
|
|
||||||
setUrl(server.url ?? "");
|
|
||||||
setHeaderPairs(Object.entries(server.headers));
|
|
||||||
setDockerImage(server.docker_image ?? "");
|
|
||||||
setContainerPort(server.container_port?.toString() ?? "3000");
|
|
||||||
}, [server]);
|
|
||||||
|
|
||||||
const saveServer = async (patch: Partial<McpServer>) => {
|
|
||||||
try {
|
|
||||||
await onUpdate({ ...server, ...patch });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update MCP server:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameBlur = () => {
|
|
||||||
if (name !== server.name) saveServer({ name });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTransportChange = (t: McpTransportType) => {
|
|
||||||
setTransportType(t);
|
|
||||||
saveServer({ transport_type: t });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommandBlur = () => {
|
|
||||||
saveServer({ command: command || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArgsBlur = () => {
|
|
||||||
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
|
|
||||||
saveServer({ args: parsed });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlBlur = () => {
|
|
||||||
saveServer({ url: url || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDockerImageBlur = () => {
|
|
||||||
saveServer({ docker_image: dockerImage || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContainerPortBlur = () => {
|
|
||||||
const port = parseInt(containerPort, 10);
|
|
||||||
saveServer({ container_port: isNaN(port) ? null : port });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEnv = (pairs: [string, string][]) => {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) env[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ env });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHeaders = (pairs: [string, string][]) => {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) headers[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ headers });
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
const isDocker = !!dockerImage;
|
|
||||||
|
|
||||||
const transportBadge = {
|
|
||||||
stdio: "Stdio",
|
|
||||||
http: "HTTP",
|
|
||||||
}[transportType];
|
|
||||||
|
|
||||||
const modeBadge = isDocker ? "Docker" : "Manual";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
|
|
||||||
<span className="text-sm font-medium truncate">{server.name}</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
|
||||||
{transportBadge}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
|
||||||
{modeBadge}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
|
|
||||||
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded config */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onBlur={handleNameBlur}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
|
|
||||||
<input
|
|
||||||
value={dockerImage}
|
|
||||||
onChange={(e) => setDockerImage(e.target.value)}
|
|
||||||
onBlur={handleDockerImageBlur}
|
|
||||||
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transport type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(["stdio", "http"] as McpTransportType[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => handleTransportChange(t)}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
|
||||||
transportType === t
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "stdio" ? "Stdio" : "HTTP"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode description */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] opacity-60">
|
|
||||||
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
|
|
||||||
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
|
|
||||||
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
|
|
||||||
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Container Port (HTTP+Docker only) */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
|
|
||||||
<input
|
|
||||||
value={containerPort}
|
|
||||||
onChange={(e) => setContainerPort(e.target.value)}
|
|
||||||
onBlur={handleContainerPortBlur}
|
|
||||||
placeholder="3000"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stdio fields */}
|
|
||||||
{transportType === "stdio" && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
|
|
||||||
<input
|
|
||||||
value={command}
|
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
|
||||||
onBlur={handleCommandBlur}
|
|
||||||
placeholder={isDocker ? "Command inside container" : "npx"}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
|
|
||||||
<input
|
|
||||||
value={args}
|
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
|
||||||
onBlur={handleArgsBlur}
|
|
||||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
|
|
||||||
{transportType === "http" && !isDocker && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
|
|
||||||
<input
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onBlur={handleUrlBlur}
|
|
||||||
placeholder="http://localhost:3000/mcp"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Headers"
|
|
||||||
pairs={headerPairs}
|
|
||||||
onChange={(pairs) => { setHeaderPairs(pairs); }}
|
|
||||||
onSave={saveHeaders}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Environment variables for HTTP+Docker */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyValueEditor({
|
|
||||||
label,
|
|
||||||
pairs,
|
|
||||||
onChange,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
pairs: [string, string][];
|
|
||||||
onChange: (pairs: [string, string][]) => void;
|
|
||||||
onSave: (pairs: [string, string][]) => void;
|
|
||||||
}) {
|
|
||||||
const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
|
|
||||||
{pairs.map(([key, value], i) => (
|
|
||||||
<div key={i} className="flex gap-1 items-center mb-1">
|
|
||||||
<input
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [e.target.value, value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="KEY"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">=</span>
|
|
||||||
<input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [key, e.target.value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="value"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const updated = pairs.filter((_, j) => j !== i);
|
|
||||||
onChange(updated);
|
|
||||||
onSave(updated);
|
|
||||||
}}
|
|
||||||
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onChange([...pairs, ["", ""]]);
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
+ Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import type { ProjectPath } from "../../lib/types";
|
import type { ProjectPath } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { inputClass, monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const formId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
nameInputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBrowse = async (index: number) => {
|
const handleBrowse = async (index: number) => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEntry = (
|
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
|
||||||
index: number,
|
|
||||||
field: keyof PathEntry,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const entries = [...pathEntries];
|
const entries = [...pathEntries];
|
||||||
entries[index] = { ...entries[index], [field]: value };
|
entries[index] = { ...entries[index], [field]: value };
|
||||||
setPathEntries(entries);
|
setPathEntries(entries);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
|
||||||
setPathEntries(pathEntries.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Add Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[30rem]"
|
||||||
|
initialFocusRef={nameInputRef}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||||
|
{loading ? "Adding…" : "Add Project"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<div>
|
||||||
|
<label
|
||||||
<form onSubmit={handleSubmit}>
|
htmlFor={`${formId}-name`}
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
className="block text-[13px] font-medium mb-1"
|
||||||
Project Name
|
>
|
||||||
|
Project name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id={`${formId}-name`}
|
||||||
ref={nameInputRef}
|
ref={nameInputRef}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="my-project"
|
placeholder="my-project"
|
||||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<div>
|
||||||
Folders
|
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||||
</label>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
{pathEntries.map((entry, i) => (
|
{pathEntries.map((entry, i) => (
|
||||||
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
<div
|
||||||
<div className="flex gap-1">
|
key={i}
|
||||||
|
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
value={entry.host_path}
|
value={entry.host_path}
|
||||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
placeholder="/path/to/folder"
|
placeholder="/path/to/folder"
|
||||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
aria-label={`Folder ${i + 1} host path`}
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||||
type="button"
|
|
||||||
onClick={() => handleBrowse(i)}
|
|
||||||
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</Button>
|
||||||
{pathEntries.length > 1 && (
|
{pathEntries.length > 1 && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="md"
|
||||||
onClick={() => removeEntry(i)}
|
variant="danger"
|
||||||
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
aria-label={`Remove folder ${i + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
x
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||||
|
/workspace/
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
value={entry.mount_name}
|
value={entry.mount_name}
|
||||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||||
placeholder="mount-name"
|
placeholder="mount-name"
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
aria-label={`Folder ${i + 1} mount name`}
|
||||||
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
className="mt-2"
|
||||||
onClick={addEntry}
|
onClick={() =>
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ Add folder
|
+ Add folder
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
<div
|
||||||
)}
|
role="alert"
|
||||||
|
className="px-2 py-1.5 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/30 rounded-[var(--radius-control)]"
|
||||||
<div className="flex justify-end gap-2">
|
>
|
||||||
<button
|
{error}
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Adding..." : "Add Project"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
import { SwitchRow, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClaudeCodeSettings | null;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||||
|
tui_mode: null,
|
||||||
|
effort: null,
|
||||||
|
auto_scroll_disabled: false,
|
||||||
|
focus_mode: false,
|
||||||
|
show_thinking_summaries: false,
|
||||||
|
enable_session_recap: false,
|
||||||
|
env_scrub: false,
|
||||||
|
prompt_caching_1h: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||||
|
return (
|
||||||
|
s.tui_mode === null &&
|
||||||
|
s.effort === null &&
|
||||||
|
s.auto_scroll_disabled === false &&
|
||||||
|
s.focus_mode === false &&
|
||||||
|
s.show_thinking_summaries === false &&
|
||||||
|
s.enable_session_recap === false &&
|
||||||
|
s.env_scrub === false &&
|
||||||
|
s.prompt_caching_1h === false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOLEAN_FIELDS: {
|
||||||
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||||
|
{
|
||||||
|
key: "show_thinking_summaries",
|
||||||
|
label: "Thinking summaries",
|
||||||
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enable_session_recap",
|
||||||
|
label: "Session recap",
|
||||||
|
hint: "Provides context when returning to a session.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auto_scroll_disabled",
|
||||||
|
label: "Auto-scroll disabled",
|
||||||
|
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "env_scrub",
|
||||||
|
label: "Env scrub",
|
||||||
|
hint: "Strips credentials from subprocess environments.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "prompt_caching_1h",
|
||||||
|
label: "Prompt caching (1h)",
|
||||||
|
hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ClaudeCodeSettingsEditor({
|
||||||
|
settings,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||||
|
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS });
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const apply = (patch: Partial<ClaudeCodeSettings>) => {
|
||||||
|
const next = { ...local, ...patch };
|
||||||
|
setLocal(next);
|
||||||
|
onSave(isAllDefaults(next) ? null : next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="TUI mode"
|
||||||
|
hint="Enables flicker-free alt-screen rendering."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.tui_mode ?? ""}
|
||||||
|
aria-label="TUI mode"
|
||||||
|
onChange={(e) => apply({ tui_mode: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="fullscreen">Fullscreen</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Effort level"
|
||||||
|
hint="Controls how much reasoning Claude applies."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.effort ?? ""}
|
||||||
|
aria-label="Effort level"
|
||||||
|
onChange={(e) => apply({ effort: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||||
|
<SwitchRow
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label={label}
|
||||||
|
checked={local[key]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: ClaudeCodeSettings | null;
|
settings: ClaudeCodeSettings | null;
|
||||||
@@ -8,184 +10,32 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: ClaudeCodeSettings = {
|
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
|
||||||
tui_mode: null,
|
export default function ClaudeCodeSettingsModal({
|
||||||
effort: null,
|
settings,
|
||||||
auto_scroll_disabled: false,
|
disabled,
|
||||||
focus_mode: false,
|
onSave,
|
||||||
show_thinking_summaries: false,
|
onClose,
|
||||||
enable_session_recap: false,
|
}: Props) {
|
||||||
env_scrub: false,
|
|
||||||
prompt_caching_1h: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
|
||||||
return (
|
return (
|
||||||
s.tui_mode === null &&
|
<Modal
|
||||||
s.effort === null &&
|
title="Claude Code Settings"
|
||||||
s.auto_scroll_disabled === false &&
|
onClose={onClose}
|
||||||
s.focus_mode === false &&
|
widthClassName="w-[34rem]"
|
||||||
s.show_thinking_summaries === false &&
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
s.enable_session_recap === false &&
|
|
||||||
s.env_scrub === false &&
|
|
||||||
s.prompt_caching_1h === false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClaudeCodeSettingsModal({ settings, disabled, onSave, onClose }: Props) {
|
|
||||||
const [local, setLocal] = useState<ClaudeCodeSettings>(settings ?? { ...DEFAULTS });
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = async (patch: Partial<ClaudeCodeSettings>) => {
|
|
||||||
const next = { ...local, ...patch };
|
|
||||||
setLocal(next);
|
|
||||||
try {
|
|
||||||
await onSave(isAllDefaults(next) ? null : next);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to save Claude Code settings:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleButton = (label: string, description: string, value: boolean, onChange: (v: boolean) => void) => (
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{label}</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">{description}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 shrink-0 ${
|
|
||||||
value
|
|
||||||
? "bg-[var(--success)] text-white"
|
|
||||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value ? "ON" : "OFF"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
<ClaudeCodeSettingsEditor
|
||||||
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</h2>
|
settings={settings}
|
||||||
|
disabled={disabled}
|
||||||
{disabled && (
|
disabledReason="Container must be stopped to change Claude Code settings."
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
onSave={async (next) => {
|
||||||
Container must be stopped to change Claude Code settings.
|
try {
|
||||||
</div>
|
await onSave(next);
|
||||||
)}
|
} catch (err) {
|
||||||
|
console.error("Failed to save Claude Code settings:", err);
|
||||||
<div className="space-y-4 mb-6">
|
}
|
||||||
{/* TUI Mode */}
|
}}
|
||||||
<div className="flex items-center justify-between gap-4">
|
/>
|
||||||
<div className="min-w-0">
|
</Modal>
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">TUI Mode</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">Enables flicker-free alt-screen rendering</div>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={local.tui_mode ?? ""}
|
|
||||||
onChange={(e) => update({ tui_mode: e.target.value || null })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
<option value="">Default</option>
|
|
||||||
<option value="fullscreen">Fullscreen</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Effort Level */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">Effort Level</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">Controls how much reasoning Claude applies</div>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={local.effort ?? ""}
|
|
||||||
onChange={(e) => update({ effort: e.target.value || null })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
<option value="">Default</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Boolean toggles */}
|
|
||||||
{toggleButton(
|
|
||||||
"Focus Mode",
|
|
||||||
"Collapses tool output to one-line summaries",
|
|
||||||
local.focus_mode,
|
|
||||||
(v) => update({ focus_mode: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Thinking Summaries",
|
|
||||||
"Shows thinking process as summaries",
|
|
||||||
local.show_thinking_summaries,
|
|
||||||
(v) => update({ show_thinking_summaries: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Session Recap",
|
|
||||||
"Provides context when returning to a session",
|
|
||||||
local.enable_session_recap,
|
|
||||||
(v) => update({ enable_session_recap: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Auto-Scroll Disabled",
|
|
||||||
"Disables auto-scroll when in fullscreen TUI mode",
|
|
||||||
local.auto_scroll_disabled,
|
|
||||||
(v) => update({ auto_scroll_disabled: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Env Scrub",
|
|
||||||
"Strips credentials from subprocess environments for security",
|
|
||||||
local.env_scrub,
|
|
||||||
(v) => update({ env_scrub: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Prompt Caching (1h)",
|
|
||||||
"Enables 1-hour prompt cache TTL instead of 5 minutes",
|
|
||||||
local.prompt_caching_1h,
|
|
||||||
(v) => update({ prompt_caching_1h: v }),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (instructions: string) => Promise<unknown>;
|
||||||
|
rows?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsEditor({
|
||||||
|
instructions: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
rows = 10,
|
||||||
|
autoFocus = false,
|
||||||
|
}: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInstructions(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
onBlur={() => onSave(instructions)}
|
||||||
|
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||||
|
aria-label="Claude instructions"
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] resize-y font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeInstructionsEditor from "./ClaudeInstructionsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
@@ -7,74 +9,35 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
|
||||||
const [instructions, setInstructions] = useState(initial);
|
export default function ClaudeInstructionsModal({
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
instructions,
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
disabled,
|
||||||
|
onSave,
|
||||||
useEffect(() => {
|
onClose,
|
||||||
textareaRef.current?.focus();
|
}: Props) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(instructions); } catch (err) {
|
|
||||||
console.error("Failed to update Claude instructions:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Claude Instructions"
|
||||||
onClick={handleOverlayClick}
|
description="Written to ~/.claude/CLAUDE.md inside containers."
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
onClose={onClose}
|
||||||
|
widthClassName="w-[40rem]"
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
|
<ClaudeInstructionsEditor
|
||||||
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
|
instructions={instructions}
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
disabled={disabled}
|
||||||
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
|
disabledReason="Container must be stopped to change Claude instructions."
|
||||||
</p>
|
rows={14}
|
||||||
|
autoFocus
|
||||||
{disabled && (
|
onSave={async (value) => {
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
try {
|
||||||
Container must be stopped to change Claude instructions.
|
await onSave(value);
|
||||||
</div>
|
} catch (err) {
|
||||||
)}
|
console.error("Failed to update Claude instructions:", err);
|
||||||
|
}
|
||||||
<textarea
|
}}
|
||||||
ref={textareaRef}
|
/>
|
||||||
value={instructions}
|
</Modal>
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="Enter instructions for Claude Code in this project's container..."
|
|
||||||
disabled={disabled}
|
|
||||||
rows={14}
|
|
||||||
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end mt-4">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -7,49 +8,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onCancel();
|
|
||||||
},
|
|
||||||
[onCancel],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Remove Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onCancel}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[26rem]"
|
||||||
>
|
footer={
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
<>
|
||||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
|
||||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="md"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Are you sure you want to remove{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||||
|
delete the container, config volume, and stored credentials.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import ConfirmResetModal from "./ConfirmResetModal";
|
||||||
|
|
||||||
|
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||||
|
async function flushFocus() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConfirmResetModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderModal() {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmResetModal
|
||||||
|
projectName="api-server"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
return { onConfirm, onCancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("names what will be lost rather than just asking to confirm", async () => {
|
||||||
|
await renderModal();
|
||||||
|
// The whole point of the gate: Reset deletes the volumes, and the two
|
||||||
|
// losses users do not expect are the login and the session transcripts.
|
||||||
|
expect(screen.getByText(/sign in again/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/session transcript/i)).toBeInTheDocument();
|
||||||
|
// And it must say what is safe, or the warning reads as "you lose everything".
|
||||||
|
expect(screen.getByText(/mounted project folders/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset until confirmed", async () => {
|
||||||
|
const { onConfirm, onCancel } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets on confirm", async () => {
|
||||||
|
const { onConfirm } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Reset container" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset is destructive in a way its name does not advertise.
|
||||||
|
*
|
||||||
|
* `rebuild_project_container` calls `remove_project_volumes`, which deletes
|
||||||
|
* both `triple-c-home-{id}` and `triple-c-claude-config-{id}` — so the OAuth
|
||||||
|
* login, any skills or agents installed in the container, and every session
|
||||||
|
* transcript go with them. That is intentional (Reset exists to get back to a
|
||||||
|
* clean base image), but it is not recoverable, so it gets the same
|
||||||
|
* confirmation gate as Remove.
|
||||||
|
*/
|
||||||
|
export default function ConfirmResetModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Reset container"
|
||||||
|
onClose={onCancel}
|
||||||
|
widthClassName="w-[28rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
|
>
|
||||||
|
Reset container
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
<p>
|
||||||
|
Rebuild{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>’s
|
||||||
|
container from the clean base image.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This deletes the container’s volumes, which means you will lose:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
your <code className="font-mono">claude login</code> — you will need to
|
||||||
|
sign in again
|
||||||
|
</li>
|
||||||
|
<li>any skills, agents or plugins installed inside the container</li>
|
||||||
|
<li>every saved session transcript, so past sessions cannot be resumed</li>
|
||||||
|
<li>anything installed with <code className="font-mono">apt</code>, <code className="font-mono">pip</code> or <code className="font-mono">npm</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Your mounted project folders are on the host and are{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">not</strong> affected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectName: string;
|
|
||||||
operation: "starting" | "stopping" | "resetting";
|
|
||||||
progressMsg: string | null;
|
|
||||||
error: string | null;
|
|
||||||
completed: boolean;
|
|
||||||
onForceStop: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationLabels: Record<string, string> = {
|
|
||||||
starting: "Starting",
|
|
||||||
stopping: "Stopping",
|
|
||||||
resetting: "Resetting",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContainerProgressModal({
|
|
||||||
projectName,
|
|
||||||
operation,
|
|
||||||
progressMsg,
|
|
||||||
error,
|
|
||||||
completed,
|
|
||||||
onForceStop,
|
|
||||||
onClose,
|
|
||||||
}: Props) {
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-close on success after 800ms
|
|
||||||
useEffect(() => {
|
|
||||||
if (completed && !error) {
|
|
||||||
const timer = setTimeout(onClose, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
// Escape to close (only when completed or error)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && (completed || error)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
|
||||||
},
|
|
||||||
[completed, error, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const inProgress = !completed && !error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">
|
|
||||||
{operationLabels[operation]} “{projectName}”
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Spinner / checkmark / error icon */}
|
|
||||||
<div className="flex justify-center mb-3">
|
|
||||||
{error ? (
|
|
||||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
|
||||||
) : completed ? (
|
|
||||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress message */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
|
||||||
{error
|
|
||||||
? <span className="text-[var(--error)]">{error}</span>
|
|
||||||
: completed
|
|
||||||
? "Done!"
|
|
||||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex justify-center gap-2">
|
|
||||||
{inProgress && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
|
||||||
>
|
|
||||||
Force Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(completed || error) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Env-var table. Used inline in Project Home → Config and in global Settings. */
|
||||||
|
export default function EnvVarsEditor({
|
||||||
|
envVars: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVars(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||||
|
const updated = [...vars];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vars.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
No environment variables configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vars.map((ev, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={ev.key}
|
||||||
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
|
onBlur={() => onSave(vars)}
|
||||||
|
placeholder="KEY"
|
||||||
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-2/5 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
|
onBlur={() => onSave(vars)}
|
||||||
|
placeholder="value"
|
||||||
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove environment variable ${ev.key || i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = vars.filter((_, j) => j !== i);
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...vars, { key: "", value: "" }];
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import EnvVarsEditor from "./EnvVarsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
envVars: EnvVar[];
|
envVars: EnvVar[];
|
||||||
@@ -8,117 +10,27 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
|
||||||
const [vars, setVars] = useState<EnvVar[]>(initial);
|
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
|
||||||
const updated = [...vars];
|
|
||||||
updated[index] = { ...updated[index], [field]: value };
|
|
||||||
setVars(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeVar = async (index: number) => {
|
|
||||||
const updated = vars.filter((_, i) => i !== index);
|
|
||||||
setVars(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to remove environment variable:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addVar = async () => {
|
|
||||||
const updated = [...vars, { key: "", value: "" }];
|
|
||||||
setVars(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to add environment variable:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(vars); } catch (err) {
|
|
||||||
console.error("Failed to update environment variables:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Environment Variables"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[36rem]"
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
<EnvVarsEditor
|
||||||
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
|
envVars={envVars}
|
||||||
|
disabled={disabled}
|
||||||
{disabled && (
|
disabledReason="Container must be stopped to change environment variables."
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
onSave={async (vars) => {
|
||||||
Container must be stopped to change environment variables.
|
try {
|
||||||
</div>
|
await onSave(vars);
|
||||||
)}
|
} catch (err) {
|
||||||
|
console.error("Failed to update environment variables:", err);
|
||||||
<div className="space-y-2 mb-4">
|
}
|
||||||
{vars.length === 0 && (
|
}}
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
|
/>
|
||||||
)}
|
</Modal>
|
||||||
{vars.map((ev, i) => (
|
|
||||||
<div key={i} className="flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
value={ev.key}
|
|
||||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="KEY"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={ev.value}
|
|
||||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="value"
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => removeVar(i)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
onClick={addVar}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add variable
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { useFileManager } from "../../hooks/useFileManager";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
|
||||||
const {
|
|
||||||
currentPath,
|
|
||||||
entries,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
navigate,
|
|
||||||
goUp,
|
|
||||||
refresh,
|
|
||||||
downloadFile,
|
|
||||||
uploadFile,
|
|
||||||
} = useFileManager(projectId);
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Load initial directory
|
|
||||||
useEffect(() => {
|
|
||||||
navigate("/workspace");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build breadcrumbs from current path
|
|
||||||
const breadcrumbs = currentPath === "/"
|
|
||||||
? [{ label: "/", path: "/" }]
|
|
||||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
|
||||||
if (i === 0) {
|
|
||||||
acc.push({ label: "/", path: "/" });
|
|
||||||
} else if (part) {
|
|
||||||
const parentPath = acc[acc.length - 1].path;
|
|
||||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
|
||||||
acc.push({ label: part, path: fullPath });
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)] flex-shrink-0">
|
|
||||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Path bar */}
|
|
||||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
|
||||||
{breadcrumbs.map((crumb, i) => (
|
|
||||||
<span key={crumb.path} className="flex items-center gap-1">
|
|
||||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(crumb.path)}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{crumb.label}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
|
||||||
{error && (
|
|
||||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && entries.length === 0 ? (
|
|
||||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<tbody>
|
|
||||||
{/* Go up entry */}
|
|
||||||
{currentPath !== "/" && (
|
|
||||||
<tr
|
|
||||||
onClick={() => goUp()}
|
|
||||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<tr
|
|
||||||
key={entry.name}
|
|
||||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
|
||||||
className={`${
|
|
||||||
entry.is_directory ? "cursor-pointer" : ""
|
|
||||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5">
|
|
||||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
|
||||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
|
||||||
{!entry.is_directory && formatSize(entry.size)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
|
||||||
{entry.modified}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
{!entry.is_directory && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadFile(entry);
|
|
||||||
}}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{entries.length === 0 && !loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
|
||||||
Empty directory
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)] flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={uploadFile}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Upload file
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import PermissionModeControl, {
|
||||||
|
effectivePermissionMode,
|
||||||
|
permissionModePatch,
|
||||||
|
} from "./PermissionModeControl";
|
||||||
|
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: "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: null,
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("effectivePermissionMode", () => {
|
||||||
|
it("falls back to the legacy boolean when permission_mode is null", () => {
|
||||||
|
expect(effectivePermissionMode(baseProject)).toBe("default");
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({ ...baseProject, full_permissions: true }),
|
||||||
|
).toBe("bypass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers permission_mode when it is set", () => {
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "plan",
|
||||||
|
full_permissions: true,
|
||||||
|
}),
|
||||||
|
).toBe("plan");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissionModePatch", () => {
|
||||||
|
it("keeps the legacy full_permissions flag in sync", () => {
|
||||||
|
expect(permissionModePatch("bypass")).toEqual({
|
||||||
|
permission_mode: "bypass",
|
||||||
|
full_permissions: true,
|
||||||
|
});
|
||||||
|
expect(permissionModePatch("acceptEdits")).toEqual({
|
||||||
|
permission_mode: "acceptEdits",
|
||||||
|
full_permissions: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PermissionModeControl", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all four modes as a radio group with the effective one checked", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
|
||||||
|
expect(group).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
||||||
|
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the picked mode", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Accept Edits" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves selection with the arrow keys", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
|
||||||
|
key: "ArrowRight",
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sandbox state beside the control", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
expect(screen.getByTestId("sandbox-state")).toHaveTextContent(
|
||||||
|
/Sandbox\s*ON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not paint Bypass as dangerous while the sandbox contains it", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{ ...baseProject, permission_mode: "bypass" }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--accent-emphasis");
|
||||||
|
expect(bypass.className).not.toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/contained by the sandbox/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses caution colour only when Bypass runs with the sandbox off", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "bypass",
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/Caution/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { PermissionMode, Project } from "../../lib/types";
|
||||||
|
import SegmentedControl, { type Segment } from "../ui/SegmentedControl";
|
||||||
|
|
||||||
|
export const PERMISSION_MODES: Segment<PermissionMode>[] = [
|
||||||
|
{ value: "plan", label: "Plan", hint: "Claude proposes a plan and makes no changes." },
|
||||||
|
{ value: "default", label: "Default", hint: "Claude asks before each tool call." },
|
||||||
|
{
|
||||||
|
value: "acceptEdits",
|
||||||
|
label: "Accept Edits",
|
||||||
|
hint: "File edits are auto-approved; other tools still prompt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bypass",
|
||||||
|
label: "Bypass",
|
||||||
|
hint: "Every tool call is auto-approved (--dangerously-skip-permissions).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `permission_mode` is nullable for projects saved before it existed; fall back
|
||||||
|
* to the legacy boolean.
|
||||||
|
*/
|
||||||
|
export function effectivePermissionMode(project: Project): PermissionMode {
|
||||||
|
return project.permission_mode ?? (project.full_permissions ? "bypass" : "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch to apply when the user picks a mode. `full_permissions` is kept in
|
||||||
|
* sync so anything still reading the legacy field cannot drift.
|
||||||
|
*/
|
||||||
|
export function permissionModePatch(mode: PermissionMode): Partial<Project> {
|
||||||
|
return { permission_mode: mode, full_permissions: mode === "bypass" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
onChange: (mode: PermissionMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Explanation of why the control is disabled, shown beneath it. */
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hero control. Per §B3.3: Bypass is only painted as caution when the
|
||||||
|
* sandbox is OFF — with the sandbox ON, bypassing prompts is contained.
|
||||||
|
*/
|
||||||
|
export default function PermissionModeControl({
|
||||||
|
project,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
|
}: Props) {
|
||||||
|
const mode = effectivePermissionMode(project);
|
||||||
|
const sandboxOn = project.sandbox_mode_enabled;
|
||||||
|
const uncontainedBypass = mode === "bypass" && !sandboxOn;
|
||||||
|
|
||||||
|
const segments = PERMISSION_MODES.map((segment) =>
|
||||||
|
segment.value === "bypass" ? { ...segment, caution: !sandboxOn } : segment,
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = PERMISSION_MODES.find((s) => s.value === mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Permission mode
|
||||||
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Permission mode"
|
||||||
|
segments={segments}
|
||||||
|
value={mode}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs text-[var(--text-secondary)]"
|
||||||
|
data-testid="sandbox-state"
|
||||||
|
>
|
||||||
|
Sandbox{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
sandboxOn ? "text-[var(--success)] font-semibold" : "text-[var(--warning)] font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sandboxOn ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
{sandboxOn ? " — bubblewrap isolation" : " — no filesystem/network isolation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-xs leading-snug ${
|
||||||
|
uncontainedBypass ? "text-[var(--warning)]" : "text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
data-testid="permission-mode-hint"
|
||||||
|
>
|
||||||
|
{uncontainedBypass
|
||||||
|
? "Caution: every tool call is auto-approved and the sandbox is off, so nothing contains what Claude runs."
|
||||||
|
: mode === "bypass"
|
||||||
|
? "Every tool call is auto-approved — contained by the sandbox."
|
||||||
|
: (active?.hint ?? "")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.status === "running" && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">
|
||||||
|
Applies to terminals opened from now on.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PortMapping } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portMappings: PortMapping[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (mappings: PortMapping[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortMappingsEditor({
|
||||||
|
portMappings: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMappings(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updatePort = (
|
||||||
|
index: number,
|
||||||
|
field: "host_port" | "container_port",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||||
|
<span className="w-[28%]">Host port</span>
|
||||||
|
<span className="w-[28%]">Container port</span>
|
||||||
|
<span className="w-[22%]">Protocol</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.map((pm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.host_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Host port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.container_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Container port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pm.protocol}
|
||||||
|
aria-label={`Protocol ${i + 1}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[i] = { ...updated[i], protocol: e.target.value };
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[22%] ${selectClass}`}
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove port mapping ${i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = mappings.filter((_, j) => j !== i);
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [
|
||||||
|
...mappings,
|
||||||
|
{ host_port: 0, container_port: 0, protocol: "tcp" },
|
||||||
|
];
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add port mapping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { PortMapping } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
portMappings: PortMapping[];
|
|
||||||
disabled: boolean;
|
|
||||||
onSave: (mappings: PortMapping[]) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
|
||||||
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProtocol = (index: number, value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
updated[index] = { ...updated[index], protocol: value };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMapping = async (index: number) => {
|
|
||||||
const updated = mappings.filter((_, i) => i !== index);
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to remove port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMapping = async () => {
|
|
||||||
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to add port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(mappings); } catch (err) {
|
|
||||||
console.error("Failed to update port mappings:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
|
||||||
Map host ports to container ports. Services can be started after the container is running.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{disabled && (
|
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
|
||||||
Container must be stopped to change port mappings.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{mappings.length === 0 && (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
|
||||||
)}
|
|
||||||
{mappings.length > 0 && (
|
|
||||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
|
||||||
<span className="w-[30%]">Host Port</span>
|
|
||||||
<span className="w-[30%]">Container Port</span>
|
|
||||||
<span className="w-[25%]">Protocol</span>
|
|
||||||
<span className="w-[15%]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mappings.map((pm, i) => (
|
|
||||||
<div key={i} className="flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.host_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.container_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={pm.protocol}
|
|
||||||
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => removeMapping(i)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
onClick={addMapping}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add port mapping
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
||||||
import ProjectCard from "./ProjectCard";
|
|
||||||
import type { Project } from "../../lib/types";
|
|
||||||
|
|
||||||
// Mock Tauri dialog plugin
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
open: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock hooks
|
|
||||||
const mockUpdate = vi.fn();
|
|
||||||
const mockStart = vi.fn();
|
|
||||||
const mockStop = vi.fn();
|
|
||||||
const mockRebuild = vi.fn();
|
|
||||||
const mockRemove = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useProjects", () => ({
|
|
||||||
useProjects: () => ({
|
|
||||||
start: mockStart,
|
|
||||||
stop: mockStop,
|
|
||||||
rebuild: mockRebuild,
|
|
||||||
remove: mockRemove,
|
|
||||||
update: mockUpdate,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useTerminal", () => ({
|
|
||||||
useTerminal: () => ({
|
|
||||||
open: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useMcpServers", () => ({
|
|
||||||
useMcpServers: () => ({
|
|
||||||
mcpServers: [],
|
|
||||||
refresh: vi.fn(),
|
|
||||||
add: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockSelectedProjectId: string | null = null;
|
|
||||||
vi.mock("../../store/appState", () => ({
|
|
||||||
useAppState: vi.fn((selector) =>
|
|
||||||
selector({
|
|
||||||
selectedProjectId: mockSelectedProjectId,
|
|
||||||
setSelectedProject: vi.fn(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockProject: Project = {
|
|
||||||
id: "test-1",
|
|
||||||
name: "Test Project",
|
|
||||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
|
||||||
container_id: null,
|
|
||||||
status: "stopped",
|
|
||||||
backend: "anthropic",
|
|
||||||
bedrock_config: null,
|
|
||||||
allow_docker_access: false,
|
|
||||||
ssh_key_path: null,
|
|
||||||
git_token: null,
|
|
||||||
git_user_name: null,
|
|
||||||
git_user_email: null,
|
|
||||||
custom_env_vars: [],
|
|
||||||
port_mappings: [],
|
|
||||||
claude_instructions: null,
|
|
||||||
enabled_mcp_servers: [],
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ProjectCard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSelectedProjectId = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders project name and path", () => {
|
|
||||||
render(<ProjectCard project={mockProject} />);
|
|
||||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("card root has min-w-0 and overflow-hidden to contain content", () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
const card = container.firstElementChild;
|
|
||||||
expect(card).not.toBeNull();
|
|
||||||
expect(card!.className).toContain("min-w-0");
|
|
||||||
expect(card!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when selected and showing config", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSelectedProjectId = "test-1";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expanded area has min-w-0 and overflow-hidden", () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
|
|
||||||
const expandedSection = container.querySelector(".ml-4.mt-2");
|
|
||||||
expect(expandedSection).not.toBeNull();
|
|
||||||
expect(expandedSection!.className).toContain("min-w-0");
|
|
||||||
expect(expandedSection!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("folder path inputs use min-w-0 to allow shrinking", async () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
|
|
||||||
// Click Config button to show config panel
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Config"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// After config is shown, check the folder host_path input has min-w-0
|
|
||||||
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
|
|
||||||
expect(hostPathInputs.length).toBeGreaterThan(0);
|
|
||||||
expect(hostPathInputs[0].className).toContain("min-w-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("config panel container has overflow-hidden", async () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
|
|
||||||
// Click Config button
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Config"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// The config panel has border-t and overflow containment classes
|
|
||||||
const allDivs = container.querySelectorAll("div");
|
|
||||||
const configPanel = Array.from(allDivs).find(
|
|
||||||
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
|
|
||||||
);
|
|
||||||
expect(configPanel).toBeDefined();
|
|
||||||
expect(configPanel!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +1,32 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import ProjectCard from "./ProjectCard";
|
import ProjectRow from "./ProjectRow";
|
||||||
import AddProjectDialog from "./AddProjectDialog";
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
<div className="flex items-center justify-between px-1 py-1 mb-1.5">
|
||||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Projects
|
Projects
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button onClick={() => setShowAdd(true)} aria-label="Add project">
|
||||||
onClick={() => setShowAdd(true)}
|
+ Add
|
||||||
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
|
</Button>
|
||||||
title="Add project"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="px-2 text-sm text-[var(--text-secondary)]">
|
<p className="px-1 text-xs text-[var(--text-secondary)]">
|
||||||
No projects yet. Click + to add one.
|
No projects yet — use “+ Add” to create one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectRow key={project.id} project={project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import ProjectRow from "./ProjectRow";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const mockStart = vi.fn();
|
||||||
|
const mockStop = vi.fn();
|
||||||
|
const mockOpenClaudeTerminal = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjectActions", () => ({
|
||||||
|
useProjectActions: () => ({
|
||||||
|
busy: false,
|
||||||
|
backingUp: false,
|
||||||
|
handleStart: mockStart,
|
||||||
|
handleStop: mockStop,
|
||||||
|
handleReset: vi.fn(),
|
||||||
|
handleBackup: vi.fn(),
|
||||||
|
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||||
|
openShell: vi.fn(),
|
||||||
|
openTerminalWithCommand: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOpenProjectHome = vi.fn();
|
||||||
|
let storeState: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
vi.mock("../../store/appState", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../store/appState")>(
|
||||||
|
"../../store/appState",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "test-1",
|
||||||
|
name: "Test Project",
|
||||||
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
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: null,
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
storeState = {
|
||||||
|
activeTabKey: null,
|
||||||
|
selectedProjectId: null,
|
||||||
|
openProjectHome: mockOpenProjectHome,
|
||||||
|
containerProgress: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectRow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders project name and mount path", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("row root has min-w-0 and overflow-hidden to contain content", () => {
|
||||||
|
const { container } = render(<ProjectRow project={baseProject} />);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(row!.className).toContain("min-w-0");
|
||||||
|
expect(row!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("communicates status with a word, not colour alone", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
|
||||||
|
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting the row opens that project's home tab instead of expanding in place", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByText("Test Project"));
|
||||||
|
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
|
||||||
|
// No config form is rendered in the sidebar any more.
|
||||||
|
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers start when stopped and stop when running", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
|
||||||
|
expect(mockStart).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||