Compare commits

...
Author SHA1 Message Date
shadow-testandClaude Opus 5 3741e0fef5 Number releases by the highest one already published, not by drift
The Linux release upload failed with a bare "exitcode '1'" and no output.
The cause was not the upload: compute-version handed it a version that
had already been released three days earlier.

The patch number was `git rev-list --count <highest tag>..HEAD` — how far
HEAD has drifted from whichever tag sorts highest, which resets to zero
every time a tag is cut. It is not a counter, and the published history
is what the old formula returned at each point:

  v0.4.0 -> 3 commits -> v0.4.3    looked fine
  v0.4.3 -> 4 commits -> v0.4.4    fine by luck, 4 > 3
  v0.4.4 -> 2 commits -> v0.4.2    went backwards
  v0.4.4 -> 6 commits -> v0.4.6    jumped, skipping .5
  v0.4.6 -> 3 commits -> v0.4.3    already taken

So the line published 0.4.0, 0.4.3, 0.4.4, 0.4.2, 0.4.6 in that order,
never used 0.4.1 or 0.4.5, and then came back round to 0.4.3.

The patch is now one past the highest already used. Suffixed tags count
towards that: create-tag is skipped whenever a platform job fails, so a
run can publish v0.4.7-mac and never create the plain v0.4.7, and reading
only unsuffixed tags would hand the same number out twice. A commit that
is already tagged reuses its own tag, so re-running a build does not mint
a version.

Reusing a number was doing real damage, not just failing. macOS and
Windows delete-then-upload each asset, so they took the duplicate in
their stride and rewrote v0.4.3-mac and v0.4.3-win — public since
Aug 11 — with today's binaries. Linux is the only platform that failed,
and failing was the correct outcome; its v0.4.3 assets are the only ones
still original.

Linux also gets the idempotent get-or-create the other two already had,
plus `set -euo pipefail` and `-fsS`. Its `curl -s` with no `-f` is why a
409 produced no diagnostic at all: the HTTP error was swallowed, the id
grep came back empty, and the step died without ever printing why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:57:36 -07:00
jknapp 0e6566d903 Merge pull request 'Fix Playwright setup destroying its own install, and the migration notice that stayed silent' (#25) from fix/playwright-container-setup into main
Build Container / build-container (push) Successful in 57s
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m45s
Build App / build-linux (push) Failing after 5m18s
Build App / create-tag (push) Skipped
Build App / sync-to-github (push) Skipped
2026-08-14 04:34:20 +00:00
shadow-testandClaude Opus 5 84a67fcd0d Stop an empty base-image label from silencing the migration notice
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m31s
Build App (Preview) / build-windows (pull_request) Successful in 6m24s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.

`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.

A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:51 -07:00
shadow-testandClaude Opus 5 f3cc1c4c17 Stop Playwright setup from deleting the package it just installed
Setting up the browser view failed on every container, and re-running it
reproduced the same broken state, because the setup destroyed its own work.

`install_packages` ran two `npm install --no-save` commands into
/workspace, which has no package.json. With no manifest, npm treats the
command line as the whole statement of what the tree should contain and
prunes the rest, so installing `playwright` second removed the
`@playwright/cli` installed first: "removed 3 packages", leaving an empty
node_modules/@playwright/ behind playwright and playwright-core. That
empty directory is exactly what the pane then reported as missing. The
second install now names both specs; the first one is already present, so
it costs nothing and is only there to stop npm pruning it.

Two failures were waiting behind that one:

Nothing in the tree ever configured the browser, so playwright-cli fell
back to channel `chrome` — system Google Chrome — with the Chromium
sandbox on. These containers forbid unprivileged user namespaces, so it
aborted with "Failed to move to new namespace ... Operation not
permitted"; on a base image without Google Chrome the same default failed
as "Chromium distribution 'chrome' is not found". entrypoint.sh now seeds
~/.playwright/cli.config.json on every start, which is the only way to
reach existing projects: ~/.playwright is inside the home volume, so an
image copy would reach new projects only.

The launch check passed for a configuration the viewer never uses. It
launched bundled chromium with no channel, which resolves to
chromium-headless-shell, while the viewer's config pins
chrome-for-testing — the full chromium build, a separate download. A
container could pass every check and still fail in the pane with 'Browser
"chrome-for-testing" is not installed', which is what a stale
chromium-1217 against a wanted chromium-1237 did. Chromium is now
verified on both channels, the sandbox setting is stated rather than
inherited from a default, and a failure names the channel.

triple-c-playwright-heal repairs all of it on a container that is already
broken, including the missing socat that makes the pane report
"127.0.0.1 sent an invalid response" while the container side is
perfectly healthy. It verifies by launching a browser rather than
trusting the preceding steps — which is how the stale-revision case was
found — and lives in /usr/local/bin so a fix to it can still reach an
existing project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:37 -07:00
jknapp 7265f55f27 Merge pull request 'Fix scheduled tasks failing to authenticate, and show when one is running' (#24) from fix/scheduler-home-clobber into main
Build Container / build-container (push) Successful in 1m52s
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m40s
Build App / build-linux (push) Successful in 7m30s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 17s
Reviewed-on: #24
2026-08-12 13:55:54 +00:00
jknapp 88f2e73474 Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
2026-08-12 13:55:37 +00:00
shadow-testandClaude Opus 5 fa4940dd7d Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 9027fa9ad4 Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.

The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.

Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:13:59 -07:00
jknapp be37723c38 Merge pull request 'Sweep the snapshot commits recreation leaves behind' (#23) from sweep-orphaned-snapshots into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m18s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #23
2026-08-12 02:06:24 +00:00
shadow-testandClaude Opus 5 5f990dd28b Sweep the snapshot commits recreation leaves behind
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m16s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Every recreation commits the container to triple-c-snapshot-{id}:latest
and moves that tag; the image it pointed at keeps its layers and loses
its name. Nothing deleted those, so they accumulate — measured on one
real host, 7 orphans holding 7.4 GB, three of them from a single day's
work.

`sweep_orphaned_snapshots` removes them, under two conditions that are
the whole safety argument. Untagged: every image the app depends on
carries a tag, so a project's live `:latest` and a migration's
`pre-migration-*` rollback pin cannot match the filter at all. And
labelled `triple-c.managed=true`, which `docker commit` copies from the
container onto the image — the user's own dangling images are not ours
to delete. Removal is unforced on top of that, so Docker refuses while
any container is still built from the image, including the stopped
containers of projects that are not running; those are counted and left
for the next sweep.

It runs after a recreation, which is when the orphan it just made
becomes removable, and after a migration is accepted, which is the
moment dropping the pin turns the pre-migration snapshot into an orphan.
Both detached: this is housekeeping, and a full disk beats a project
that will not start. Each sweep clears every orphan it finds, so
recreations that predate it are cleaned up too.

The label string is now a constant rather than four literals, and a test
pins both filter conditions in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:01:20 -07:00
jknapp 4df59da2d8 Merge pull request #22: Give the env var its value box back, and stop labelling the secret
Build App / compute-version (push) Successful in 18s
Build App / build-macos (push) Successful in 2m47s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m47s
Build App / create-tag (push) Successful in 26s
Build App / sync-to-github (push) Successful in 13s
2026-08-11 22:34:30 +00:00
jknapp a72406f0d8 Merge pull request #21: Bring the README back in step with the code, and give it a spine 2026-08-11 22:34:15 +00:00
shadow-testandClaude Opus 5 9b2f4fe79f Give the env var its value box back, and stop labelling the secret
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m33s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Two separate faults, both reachable from one screenshot of the Global
Environment Variables editor.

The value input was collapsed to a sliver, so a variable looked like it
had lost its value. `inputClass` carries `w-full`, and the `w-2/5` on the
key input did not beat it — class-attribute order is not what resolves
that conflict, stylesheet order is. The key therefore asked for the whole
row, and the value input, whose `flex-1` gives it a basis of 0 and only
the leftover space, got almost nothing. Widths now live on wrapper divs,
where nothing competes with them.

The fingerprint that detects custom-env changes was a plaintext
`KEY=VALUE` join, and it is written as the `triple-c.custom-env-fingerprint`
label. Labels are readable by anything on the host via `docker inspect`,
`docker commit` copies them onto the project's snapshot image, and the
recreation check logs both sides on a mismatch — so an API token set as a
custom variable was published to all three. It is hashed now, exactly as
`triple-c.git-token-hash` already was. Empty stays empty, so "nothing
configured" still reads as an empty label.

Changing the fingerprint format means every project's label mismatches
once: expect a single container recreation per project on next start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:27:19 -07:00
shadow-testandClaude Opus 5 e9ec2f8e26 Bring the README back in step with the code, and give it a spine
Four feature commits landed after the last doc sweep without reaching the
README, and cc5f691 documented only half of what it shipped. Adds the
sections for browser view, corporate CA certificates, base-image
migration and the LiteLLM model gateway, and refreshes the Key Files
table, which was missing about ten modules.

Fixes what had gone stale: the AWS directory mounts read-only at
/tmp/.host-aws and is copied in by the entrypoint, not mounted at
~/.aws; Project Home has six tabs, not five; the Automation tab creates
tasks as well as running them; Ctrl+Shift+left/right moves the active
tab, and tabs can be dragged.

Structurally, the sections are now grouped under Containers, Models and
Authentication, Bridges to the Host, and Inside a Project, with a
contents list and a table pointing at the other docs. Every section from
before survives, in the same words where nothing changed — the file was
414 lines of internals with no way in and no map of where anything was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:28:52 -07:00
jknapp fa82d54afa Merge pull request #20: New app icon: a mark that survives being 16 pixels tall
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m46s
Build App / build-linux (push) Successful in 5m22s
Build App / build-windows (push) Successful in 5m28s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-08-11 19:04:49 +00:00
shadow-testandClaude Opus 5 4c962ebd9c Archive the marks the new icon replaces
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 5m29s
Build App (Preview) / build-linux (pull_request) Successful in 5m41s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Neither is referenced by the app or the build; branding/archive/README.md
says what each one was and why it did not survive the sizes an app icon
is actually drawn at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:03:28 -07:00
shadow-test d15faa923b Give the app a mark that survives being 16 pixels tall
The icon is now a container with its right wall opened, so the enclosure
itself is the letter C, holding a >_ prompt: the two things the app is,
in one closed shape. It carries no type, so nothing goes illegible when
the shell draws it small, and it uses the app's own accent tokens rather
than a saturated orange field that fights the chrome behind it.

icon.ico contained a single 16x16 image, which Windows was upscaling into
the taskbar and every other slot — the likely cause of the artefact in
screenshot_for_fix/. It now carries 16, 24, 32, 48, 64, 128 and 256, each
rendered from vector rather than downsampled from one bitmap, and the
entries at 32 and below come from a separate optical source: at that size
the cursor bar closes up against the chevron, so the small variant drops
it, widens the mouth and thickens the strokes. A test asserts the .ico
keeps its small sizes so this cannot regress silently.

Also adds the icon.icns that macOS bundles have been building without,
points the favicon at our own mark instead of the missing /vite.svg, and
puts the SVG sources, the lockups and the regeneration script in
branding/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:01:39 -07:00
jknapp e379c58684 Merge pull request #19: Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-linux (push) Successful in 5m17s
Build App / build-windows (push) Successful in 5m28s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
Tab reordering (drag, with a dragged copy under the cursor, or Ctrl+Shift+arrow) and the browser view in a window of its own, with Keep-on-top and match-window.

Also, from using it: open a page in the container's browser at a chosen viewport, reachable from the Browser tab and the terminal's URL prompt; the Playwright split-tree bug that made every require("playwright") fail while the pane read green; the URL relay opening a different URL than the one on screen; preview builds that publish as prereleases, labelled with the version they preview, on the 0.4 line; and one CI run per push instead of two.
2026-08-11 18:20:15 +00:00
shadow-testandClaude Opus 5 ab747ce53d Say what "open in container" is doing, and land on the pane doing it
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Opening a page is a container probe, a browser launch, a page load and
often a viewer start — several seconds during which the only feedback
was the click itself. Worse from a terminal, where the result appears in
a pane the user is not looking at.

So: the backend emits progress on the existing `container-progress`
channel at each step, the Browser tab renders that line whenever it is
set — the progress belongs to the project, not to whoever pressed the
button, which is what lets a terminal-initiated open report anywhere at
all — and the terminal's "In container" now selects the project's
Browser tab before starting, so the line has somewhere to appear.

Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it
in local state, so `openProjectHomeTab` parks a request in the store and
the pane consumes it once. Consumed once, so it cannot fight the user's
own clicking afterwards.

Preview releases now prune themselves to the newest KEEP_PREVIEWS (2),
in a job that runs only if all three platforms published — a
half-finished run must not evict a good older build. The cleanup
workflow's manual sweep stays as the backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:13:21 -07:00
shadow-testandClaude Opus 5 85ea3956e8 Stop the drag from selecting the tab's text
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m39s
Build App (Preview) / build-linux (pull_request) Successful in 5m56s
A pointer-driven drag is still a mouse drag as far as the browser is
concerned, so moving a tab highlighted its label blue — something the OS
drag image never did, and the last visible difference between this and
a real drag.

`select-none` on the tab. The rename field gets `select-text` back:
`user-select` inherits, and selecting text is exactly what that field is
for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:00:06 -07:00
shadow-testandClaude Opus 5 5bd80a05bc One build per push: previews carry the PR check
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m27s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Every push to the PR started two workflows on the same commit.
build-app.yml ran on pull_request and compiled all three platforms —
then published nothing, because every publishing step in it is gated on
`gitea.event_name == 'push'`. build-app-preview.yml compiled the same
three and published them. Six OS builds per push, half of them
unreachable.

So the PR trigger moves to the preview workflow, which was already doing
the identical compilation and has something to show for it.
build-app.yml is now push-to-main and manual dispatch only: releases.

Two things a pull_request event changes, handled rather than inherited:
`gitea.sha` can be the merge ref — not the commit anyone is testing, and
not something to hang a tag on — so the release's target comes from
`git rev-parse HEAD` in the checkout; and `gitea.ref_name` is the PR
number, so the release body uses `gitea.head_ref` when there is one.

The cost is one prerelease per PR commit touching app/**, which the
existing Cleanup Old Releases sweep already prunes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:41:35 -07:00
shadow-testandClaude Opus 5 f239fa1c82 Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
**The drag showed no tab.** Moving to pointer events lost the drag image
the OS used to supply, leaving a dimmed source tab and a 2px line —
which reads as "some setting changed", not "I am holding this tab". A
copy of the tab now follows the cursor, carrying its glyph and its real
label, grabbed at the offset it was picked up by so it sits where the
tab was.

**The URL relay opened a different URL than the one on screen.**
Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as
`repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The
detector deleted *every* line break to undo PTY hard-wrapping, but a
terminal that wraps at a space emits the break **instead of** the space
— so deleting breaks also deletes the separators, gluing the following
paragraph onto the link and running the match past the host.

Only breaks the terminal inserted may be deleted, and those are exactly
the ones at the column width. The detector now takes a live column
getter and rejoins a line only when it is exactly that wide; every other
break becomes a space, which is also what stops a URL match. Lines
*longer* than the width are left alone — the stream had no break there,
so the one that follows is the application's own.

One case stays ambiguous: a URL whose length is an exact multiple of the
width is indistinguishable from one that was cut. That is pinned in a
test as known behaviour rather than papered over — the candidate is
shown in full and nothing opens without the user pressing Open.

**"In container" opened a page nobody could see.** It bound the browser
and stopped, leaving the user to find the Browser tab and press Start,
with nothing saying so — and from a terminal, no pane on screen at all.
Opening a page now starts the viewer if it isn't running, and the
terminal's prompt raises the pop-out window, because that caller has
nowhere else to put it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:36:30 -07:00
shadow-testandClaude Opus 5 5b18ce804f Start the 0.4 line, and give previews the version they are previewing
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m34s
Build App / build-windows (pull_request) Successful in 5m17s
Build App / build-linux (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two version problems, one of them mine.

**Previews claimed x.y.0.** The preview workflow hard-coded the patch
number, so every preview installer reported 0.3.0 whatever it contained,
while the real build computes the patch from tags. It now runs the same
computation, so a preview is labelled with the version the release it
previews would carry.

**A new minor line started at the wrong number.** `compute-version`'s
fallback for "no tag matches this line yet" counted every commit in the
repository — fine as a bootstrap, wrong the moment a minor version is
bumped: the first 0.4 build would have been 0.4.234. A line nobody has
tagged is a new line, and a new line starts at .0.

With those fixed, VERSION moves to 0.4 — tab reordering, the browser
pop-out, opening pages in the container's browser and the Playwright
install fix are more than a patch bump. The next release is v0.4.0;
today's HEAD would have been 0.3.90 on the old line.

`app/package.json`, `package-lock.json`, `tauri.conf.json` and
`Cargo.toml` follow to 0.4.0. CI patches all four per build, so they are
the dev-time defaults rather than the source of truth — but a local
`tauri dev` shows them, so they should not still say 0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:06:00 -07:00
shadow-testandClaude Opus 5 63f3c54b95 Publish preview builds as a prerelease instead of workflow artifacts
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m30s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Workflow artifacts do not work on this Gitea, in two different ways:

  * upload-artifact@v4 cannot run at all. @actions/artifact v2's 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 it to this instance, so all three
    platforms died with GHESNotSupportedError — after paying for the
    whole Tauri build (run #265).
  * @v3 uploads succeed and the files are downloadable by direct URL,
    but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns
    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 way every other workflow here does: curl to the
releases API. One prerelease per preview, tagged `preview-<sha>`, with
all three platforms' bundles as assets — visible on the Releases page
with stable links.

The release is created in a job the three builds depend on rather than
get-or-created in each. They run concurrently, so per-job creation races
on one tag: the loser gets a 409, and the id parse then yields empty
while the step still reports success — the failure build-app.yml's
macOS job was hardened against after it happened for real. One creator
removes the race instead of handling it.

Asset upload keeps that hardening: delete-then-upload so a re-dispatch
replaces rather than 409s, --http1.1 and retries for the mid-stream
drops the macOS runner has produced (curl exit 92, exit 28), and an
explicit failure when a platform produced no bundles at all.

The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent
`v<x>.<y>.<z>` releases and separately deletes every release whose tag
does not start with `v[0-9]`, so previews are pruned by the cleanup
already in use and never crowd the real release list. sync-release.yml
is dispatch-only, so none of this reaches GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:38:06 -07:00
shadow-testandClaude Opus 5 f68d9c5788 Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.

Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.

Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.

The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.

Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:15:12 -07:00
shadow-testandClaude Opus 5 bd72781482 Install one Playwright tree, and notice when a container has two
Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.

The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:

  Executable doesn't exist at …/chromium_headless_shell-1234/…

while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.

So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.

Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.

An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:04:06 -07:00
shadow-testandClaude Opus 5 1207a21aae Pin the preview build's uploads to upload-artifact@v3
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m32s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Run #265 — this workflow's first ever run — built the app on all three
platforms and then lost every bundle at the upload step:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

v4 bundles @actions/artifact v2, whose isGhes() treats any
GITHUB_SERVER_URL that is not github.com, *.ghe.com or *.localhost as
GitHub Enterprise Server and throws before making a single request.
act_runner sets that variable to this Gitea instance, so v4 cannot work
here on any runner or any OS — and it fails *after* the whole Tauri
build has been paid for.

v3 uses the v1 artifact API, which Gitea implements. Both options this
workflow relies on, `if-no-files-found: error` and `retention-days`,
exist in v3.

The other workflows never hit this because they publish by curling the
Gitea releases API instead. Noted at the top of the file, with the
isGhes rule, so the pin is not "upgraded" back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:00:06 -07:00
shadow-testandClaude Opus 5 a41d93ea46 Fix the review's findings: drag on pointer events, read the window back
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Ten findings from the review of the previous commit, all applied.

**The tab drag is now pointer events, not HTML5 drag-and-drop.** Two
independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks
HTML5 drag inside the webview on Windows, and it cannot just be turned
off — `TerminalView` needs Tauri's native drag-drop event, which is the
only one that carries dropped *file paths*. And an HTML5 drag carries a
`DataTransfer`: released over any text field in the app, the default
handler types `term:<uuid>` into it, and in Config that is then saved
with the project. Pointer events have neither problem, and the drag is
measured from the tabs on screen rather than from the event target, so
the marker and the drop agree even over the marker itself. Escape
abandons a drag; a press under 4px stays a click; the click that ends a
drag does not select.

**`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound
on `document` in the capture phase, so in any input — the rename field,
Config, Settings — it was taking the OS's extend-selection chord *and*
silently reordering the strip. Guarded by `inTextField()`, which
excludes xterm's helper textarea: that is an input-method shim, and the
terminal is where the shortcut matters most.

**The pop-out's state is read from the window, never remembered.** The
pane is unmounted whenever another Project Home sub-tab is selected, so
"Keep on top" came back Off over a window still floating on top.
`get_browser_view_popout_state` returns both facts from the window
itself, and the change event carries them. `poppedOut` is tri-state:
until the answer arrives the iframe is not mounted, because guessing
"not popped out" is what flashes a second viewer onto the browser.

Also: `popout::close` and the off-status emit in the supervisor are
behind the same epoch guard as the deregistration above them, so a
supervisor whose teardown outlives a restart can no longer destroy the
*new* session's window; `close()` returns its `destroy()` error instead
of logging it and reporting success, since the pane restores its iframe
on success; the drop marker is `pointer-events-none` and is placed
before the first *visible* tab at or past the slot, so it neither
refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and
the "Keep on top" Toggle's accessible name now matches its visible text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 07:40:51 -07:00
shadow-testandClaude Opus 5 d73096c937 Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two things the UI couldn't do: rearrange the tab strip, and watch the
browser while working somewhere else.

**Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on
the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for
the same thing without a mouse. Reordering deliberately does not select
what it moves, so a drag aimed at a background tab doesn't yank the main
area away from a terminal mid-run. A tab being renamed is not draggable —
a draggable ancestor swallows the mouse-drag that selects text in its
input.

**Pop the browser view out.** `browser_view/popout.rs` opens the view's
existing token-bearing loopback URL as a second OS window, with a
"Keep on top" toggle so it can float above the app. Window-only: the
viewer, the proxy and the container are untouched, so popping out and
back interrupts nothing.

Three things it rests on:

- No capability lists that window, so it has no IPC surface — right for a
  page served out of a container, and it must stay that way.
- The app CSP is irrelevant to it: `frame-src` constrains what the app's
  document may *embed*, and this is a top-level document. The port range
  and the token gate are what actually protect it, unchanged.
- The window is owned by the session, so the supervisor's teardown closes
  it. A window onto a viewer that no longer exists is worse than none.

The pane drops its iframe while popped out — two viewers can both *drive*
the browser, and two cursors on one page is not a feature.

`lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It
fires for every window and its body stops every container and exits, so
without the guard closing a pop-out would quit the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 06:50:40 -07:00
jknapp 57b6b71772 Merge pull request #18: Inject the corporate CA certificate into containers
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m12s
Build App / build-macos (push) Successful in 2m40s
Build App / build-windows (push) Successful in 5m31s
Build App / build-linux (push) Successful in 5m22s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-08-10 18:48:53 +00:00
shadow-test 77567ac2ae Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
# Conflicts:
#	app/src/lib/tauri-commands.ts
2026-08-10 11:20:23 -07:00
jknapp 247f03b48c Merge pull request #17: Browser view — find every Playwright, set one up in two clicks, bake the runtime libraries
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m18s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m25s
Build App / build-linux (push) Successful in 5m19s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-08-10 18:19:25 +00:00
jknapp 31c73adb13 Merge pull request #16: Fix shared Claude auth — whole sign-in URL, recoverable rejected code
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-08-10 17:58:57 +00:00
shadow-testandClaude Opus 5 4fdfed7955 Bake the browser's runtime libraries into the base image
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.

Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.

The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.

The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `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.

Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.

Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.

`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, 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, so the verdict is read from its output.

Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:57:21 -07:00
Claude 7a5823cb2b Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.

A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.

Four things this gets right that are easy to get wrong:

* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
  copied in is ignored in silence. Certificates are renamed, by
  container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
  needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
  Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
  (libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
  a terminal is a docker exec and sees nothing the entrypoint exported. They are
  emitted empty when no CA is configured, since docker commit bakes env into the
  snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
  a CA rotated in at the same location still forces a recreation.

Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:40:21 -07:00
shadow-testandClaude Opus 5 a5bcc462a7 Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.

It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.

The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.

New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:

  * playwright + @playwright/cli into /workspace as `claude`, --no-save.
    /workspace is not a bind mount (projects mount at
    /workspace/{mount_name}), so nothing of the user's is touched, no sudo
    is needed, and Node resolves it from scripts in the project.
  * A browser, as its own action with the size stated first: apt libraries
    as root, then the download, then a real headless launch to prove it
    works. The base image ships none of Chromium's shared libraries, which
    is why a download could succeed and the browser still not start.
    Chromium and the Chrome channel are both offered — @playwright/mcp
    asks for `chrome` specifically. A certificate failure is reported as a
    container trust-store problem rather than a broken install.

Installing is always user-initiated; opening the tab only probes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:15:02 -07:00
shadow-testandClaude Opus 5 c3f92674b1 Fix the shared Claude auth flow: whole sign-in URL, recoverable rejected code
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two compounding bugs made `claude setup-token` unusable, both measured against
2.1.226 under a pty rather than reasoned about.

**The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and
slices the *visible* text of that hyperlink to the terminal width: 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. The
transcript scraper picked up the first slice — a URL that parses, points at
claude.com, and cannot authorise anything. The ANSI stripper now surfaces the
OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over
the scraped text. It still goes through `sanitizeRelayUrl` with the
ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an
OSC 8 parameter is never rendered, which makes it the easier place to hide a
hostile host, not a trusted one. The wrapped-display fallback is kept for CLI
versions that print a bare URL.

**A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error:
Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting;
nothing recognised that, so the exec sat until the 15-minute timeout with the UI
still saying "Finishing sign-in". Given the first bug handed the user a truncated
URL, an invalid code was the likely first outcome. The streamed output is now
scanned for that message, `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. An
undeterminable exec exit status is logged rather than silently read as success.

**A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an
80-column fallback splits the ~103 character token across two lines: the parser
saw a too-short fragment and failed, while the redactor masked the first line —
which carries the `sk-ant-` marker — and printed the second, the tail of a live
credential, to the UI in clear. `scan_credential_body` now reassembles a run
across hard wraps and both the parser and the redactor use it, so they cannot
disagree about where a credential ends. A join only happens across a break at a
plausible terminal margin (>= 40 columns) and only for a run not already long
enough to be a whole credential — without that second guard a repainting TUI
welds one frame's token onto the next frame's first word. The length floor is
applied to the reassembled body, so a fragment is still never accepted.

Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is
handled as the three-byte charset designation it is — it prefixes every repaint
frame, and treating it as two bytes emitted a stray `B` that could glue itself
onto a token and make the parser refuse it.

`submit_claude_token_code`'s single-write behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 09:57:16 -07:00
jknapp 584fcdd837 Merge pull request #15: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (push) Successful in 16s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m21s
Build App / build-linux (push) Successful in 5m48s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 14s
Moves the last hand-made piece of the Windows fix into version control. Verified by deleting the manual junctions from the build VM first, so CI had to recreate them from scratch.
2026-08-10 15:00:25 +00:00
shadow-testandClaude Opus 5 2c014fd752 CI: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m29s
Build App / build-windows (pull_request) Successful in 5m52s
Build App / build-linux (pull_request) Successful in 6m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The Windows fix was the only part of it living outside git — two
junctions created by hand on the build VM. Rebuild that VM, add a second
Windows runner, or reset the SYSTEM profile and Windows builds break
again with an error that points nowhere near the cause.

Tauri downloads candle.exe, light.exe and makensis.exe, and all three
are 32-bit. A runner running as SYSTEM has %LOCALAPPDATA% under
C:\Windows\System32\config\systemprofile, and WOW64 redirection serves
32-bit processes reading System32 from SysWOW64, where those directories
do not exist. The bundlers cannot see their own folder: candle exits
0x80131700, makensis reports "Unable to start child process, error 0x2",
and Tauri surfaces neither — only "failed to run candle.exe".

The job now junctions the SysWOW64 view onto the System32 originals when
it detects a profile inside System32, and skips entirely otherwise, so a
runner running as a normal user is unaffected. Idempotent, and written
with goto rather than nested blocks to avoid the delayed-expansion trap
that already bit the MSVC step.

Verified rather than assumed: the hand-made junctions were deleted from
the build VM before this was pushed, so this run has to recreate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 07:52:34 -07:00
jknapp 43e9959e40 Merge pull request 'Add llama.cpp + OpenAI backends, URL relay, browser view, and base-image migration' (#14) from feature/model-backends-and-browser into main
Build App / compute-version (push) Successful in 12s
Build Container / build-container (push) Successful in 50s
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 5m25s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 14s
2026-08-10 06:23:43 +00:00
jknapp e05156fd0e Merge branch 'main' into feature/model-backends-and-browser
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 33s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m12s
Build App / build-linux (pull_request) Successful in 7m40s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
2026-08-10 06:13:40 +00:00
jknapp aca6c49e3c Merge pull request #13: Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth
Build App / compute-version (push) Successful in 11s
Build Container / build-container (push) Successful in 1m45s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 5m6s
Build App / build-linux (push) Successful in 6m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Claude Code absorbed MCP natively, so Triple-C stops managing it. Also adds permission modes, Project Home, Tier-1 accessibility fixes, the Auth Bridge, shared Claude authentication and the scheduler UI.

8 commits plus review fixes. 87 frontend tests, 34 Rust tests.
2026-08-10 05:43:09 +00:00
shadow-testandClaude Opus 5 0aa8315514 CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m26s
Build App / build-windows (pull_request) Successful in 4m51s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help: makensis.exe is 32-bit like candle.exe
and failed the same way ("Unable to start child process, error 0x2").
The cause was WOW64 redirection sending 32-bit processes reading
C:\Windows\System32 to SysWOW64, where the toolset directory does not
exist.

The build VM now carries junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified on the runner: candle.exe reports WiX
3.14.1.8722 and makensis reports v3.11, both exiting 0 from the path
that previously failed.

Both targets build again, so the .msi comes back. Artifact collection
fails if either installer is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:34:59 -07:00
shadow-testandClaude Opus 5 29fd7de909 CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m40s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m13s
Build App / build-linux (pull_request) Successful in 6m29s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help, because the problem was never WiX.
makensis.exe is 32-bit exactly like candle.exe and light.exe, lives in
the same SYSTEM-profile cache, and failed the same way — "Unable to
start child process, error 0x2" instead of 0x80131700.

The cause is WOW64 redirection: a 32-bit process reading
C:\Windows\System32 is served C:\Windows\SysWOW64, where the toolset
directory does not exist, so the bundlers cannot see their own folder.

The build VM now carries two junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified afterwards on the runner: candle.exe
reports "WiX Toolset Compiler version 3.14.1.8722" and exits 0, and
makensis reports v3.11 and exits 0 — both from the same path that failed
before.

So both targets build again and the .msi asset comes back. Artifact
collection fails if either installer is missing rather than tolerating
an empty directory.

This is a host-side patch for a runner running as SYSTEM. A runner
running as a normal user has a LOCALAPPDATA outside System32 and needs
none of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:34:43 -07:00
shadow-testandClaude Opus 5 fdc161fd9c CI: build NSIS only on Windows, dropping the MSI target
Build App / compute-version (pull_request) Successful in 17s
Build Container / build-container (pull_request) Successful in 1m22s
Build App / build-macos (pull_request) Successful in 2m22s
Build App / build-windows (pull_request) Failing after 4m41s
Build App / build-linux (pull_request) Successful in 5m51s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. On a SYSTEM-run runner Tauri
caches WiX under C:\Windows\system32\config\systemprofile\..., and WOW64
redirection sends 32-bit processes to SysWOW64 where that directory does
not exist, so candle exits 0x80131700. Tauri aborts the whole bundle on
one target's failure, so the MSI was suppressing the NSIS installer too
and Windows produced no artifact at all.

NSIS is what the project already relies on for Windows upgrades. Drops
the .NET 3.5 gate, which existed only for WiX; keeps the MSVC step,
which is what makes the app link. Artifact collection now fails when no
installer is produced rather than tolerating an empty directory.

To restore the MSI, run the runner as a normal user and set
--bundles msi,nsis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:24:42 -07:00
shadow-testandClaude Opus 5 98a6c8fd56 CI: build NSIS only on Windows, dropping the MSI target
The MSI target needs WiX, whose candle.exe and light.exe are 32-bit. On
a runner running as SYSTEM, Tauri caches the WiX toolset under
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\..., and WOW64
redirection points 32-bit processes at SysWOW64, where that directory
does not exist. candle.exe cannot see its own folder, the CLR fails to
start, and it exits 0x80131700 — surfaced only as "failed to run
candle.exe".

Proven by running the identical toolset, as the same SYSTEM identity,
from C:\wixtest (exit 0) versus the systemprofile path (0x80131700).

Because Tauri aborts the entire bundle when one target fails, the MSI
was also suppressing the NSIS installer — so Windows produced no
artifact at all. NSIS is what the project already relies on for Windows
upgrades, so dropping MSI costs the .msi asset and nothing else.

Removes the .NET 3.5 gate, which only existed for WiX. The MSVC step
stays: that is what makes the app link, and it works.

Artifact collection now fails when no NSIS installer is present instead
of tolerating an empty directory with 2>nul, so a silent packaging
regression cannot pass as a green build again.

To restore the MSI later, run the runner as a normal user — whose
LOCALAPPDATA sits outside System32 — and set --bundles msi,nsis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:24:29 -07:00
shadow-testandClaude Opus 5 763af91042 Revert: LOCALAPPDATA override does not move Tauri's WiX cache
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Failing after 4m35s
Build App / build-linux (pull_request) Successful in 5m6s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Rust's `dirs` crate resolves LOCALAPPDATA on Windows through
SHGetKnownFolderPath, which reads the process token rather than the
environment, so the override changed nothing and 32-bit candle.exe still
hit WOW64 redirection under the SYSTEM profile.

Removing it rather than leaving a plausible-looking non-fix in the
workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:19:12 -07:00
shadow-testandClaude Opus 5 c71e54a35f Revert: LOCALAPPDATA override does not move Tauri's WiX cache
It looked right and did nothing. Rust's `dirs` crate resolves
LOCALAPPDATA on Windows through SHGetKnownFolderPath, which reads the
process token rather than the environment, so Tauri still cached the WiX
toolset under the SYSTEM profile and 32-bit candle.exe still hit WOW64
redirection.

Removing it rather than leaving a plausible-looking non-fix in the
workflow. The diagnosis in the previous commit stands; only the remedy
was wrong. Running the runner as a normal user, whose token resolves
LOCALAPPDATA outside System32, is the actual fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:18:58 -07:00
shadow-testandClaude Opus 5 03384409e7 CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
Build App / compute-version (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 44s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / build-windows (pull_request) Failing after 4m39s
Build App / build-linux (pull_request) Successful in 6m44s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. A SYSTEM-run runner has
%LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, where
Tauri caches the WiX toolset — and WOW64 redirection sends 32-bit
processes reading System32 to SysWOW64, which has no such directory. The
CLR then fails to start with 0x80131700 and Tauri reports only "failed
to run candle.exe".

Verified: the same binary and identity exits 0 from C:\wixtest and
0x80131700 from the systemprofile path.

Pointing LOCALAPPDATA outside System32 avoids redirection, needs no
stored credential, and is a no-op for runners already running as a
normal user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:12:27 -07:00
shadow-testandClaude Opus 5 b41077e799 CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
build-windows compiled and linked fine but died at bundling with only
"failed to run candle.exe". The real cause was neither .NET nor the
runner identity, both of which I chased first and was wrong about.

candle.exe and light.exe are 32-bit. A runner running as SYSTEM has
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\AppData\Local,
which is where Tauri caches the WiX toolset. WOW64 redirection sends any
32-bit process reading C:\Windows\System32 to C:\Windows\SysWOW64 — and
the WixTools directory exists only in the 64-bit view. So candle.exe
could not see its own directory, the CLR failed to start, and the
process exited 0x80131700, surfaced in the Application event log as
".NET Runtime version 4.0.30319.0 - This application could not be
started."

Proven rather than assumed: copying the identical toolset to C:\wixtest
and running it as the same SYSTEM identity exits 0, while the
systemprofile path exits 0x80131700. Test-Path confirms the WOW64 view
of that directory does not exist.

Pointing LOCALAPPDATA at a path outside System32 avoids redirection.
This fixes it for any runner running as a service or as SYSTEM, without
needing a stored user credential, and is a no-op where the runner
already runs as a normal user.

For the record, two earlier theories were wrong. .NET 3.5 was missing
and is now installed from the ISO payload, but candle targets .NET 4.x
(its config uses loadFromRemoteSources, a 4.0-only element) so that was
never the blocker. Adding explicit supportedRuntime entries changed
nothing. Both are documented here so the next person does not repeat
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:12:11 -07:00
shadow-testandClaude Opus 5 c6bb7fdf1d CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-windows (pull_request) Failing after 5m14s
Build App / build-linux (pull_request) Successful in 6m2s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
%VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are
substituted at parse time, not run time, so the installer's real exit
code was never read. Uses delayed expansion now.

Also checks for the .NET 3.5 runtime before building: WiX candle.exe
needs it, and Tauri aborts the whole bundle when the MSI target fails,
which silently suppresses the NSIS installer too. Fails early with the
exact dism command rather than at bundle time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:15 -07:00
shadow-testandClaude Opus 5 704d3b8f79 CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Two follow-ups to the provisioning step.

The exit-code check never worked. %VSEXIT% and %ERRORLEVEL% inside a
parenthesised cmd block are substituted when the block is PARSED, not
when it runs, so the installer's real result was never read — the log
printed "installer failed with " with an empty code, then continued
anyway. It happened to be harmless because the install had in fact
succeeded, but a genuine failure would have sailed past. Now uses
delayed expansion.

Added a .NET 3.5 check. WiX 3.x candle.exe is a .NET 2.0/3.5
application, and Tauri aborts the entire bundle when the MSI target
fails — so a missing runtime silently costs the NSIS installer too, not
just the MSI. Windows 11 ships NetFx3 as DisabledWithPayloadRemoved and
Windows Update could not supply the payload on our runner even across a
reboot; it needed /Source from a mounted ISO. Rather than guess, the job
now fails early with the exact dism command.

Verified on the runner: MSVC Build Tools 2022 installed, the Rust build
completed in 3m59s and produced triple-c.exe, and NetFx3 is now Enabled
with v2.0.50727 present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:02 -07:00
shadow-testandClaude Opus 5 ca0f944712 CI: install MSVC build tools on Windows runners that lack them
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m24s
Build App / build-windows (pull_request) Failing after 15m29s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
build-windows failed on this PR with "linker `link.exe` not found",
while build-linux and build-container passed — the code was fine, the
runner environment was not.

The job installs Rust and Node conditionally but assumed the MSVC C++
toolchain was hand-provisioned. A runner without it registers normally,
advertises windows-latest, accepts the job, downloads the entire crate
graph and only then fails at link time. That also means a bare runner
coming online turns a job that would have queued for a capable machine
into a failed build.

Installs the VC++ workload when vswhere cannot find it, matching the
existing conditional Rust and Node steps. rustc locates MSVC through
vswhere and the registry rather than PATH, so no dev-shell activation is
needed. Installer exit 3010 (success, reboot pending) is treated as
success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:36:14 -07:00
shadow-testandClaude Opus 5 2de00b3c55 Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.

Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.

The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.

URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.

Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.

Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.

Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.

192 Rust tests, 274 frontend tests, both builds clean, zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:35:39 -07:00
shadow-testandClaude Opus 5 eb1324cb16 Fix pre-auth bypass in the browser-view proxy
read_head computed the head terminator index and discarded it, returning
the whole receive buffer. authorize() then split that buffer on CRLF and
treated every colon-bearing line as a header, last occurrence winning —
so any bytes a client sent after the head were promoted to headers.

A cross-site fetch with a text/plain body is CORS-safelisted and not
preflighted, so a body of

    a=x\r\nSec-Fetch-Site: same-origin\r\n

overwrote the real cross-site value and the gate returned Allow. The
same trick overrode Host, defeating the anti-rebinding check too. The
result was unauthenticated mouse, keyboard and CDP control of a browser
running inside a container that has passwordless sudo — reachable from
any page the user happened to visit, with the port range being a fixed
8-wide window that is trivially scanned.

read_head now returns the terminator index and the caller authorizes
against that slice only, while still replaying the full buffer into the
tunnel so pipelined bodies are not lost. Duplicate Host, Origin and
Sec-Fetch-Site headers are now refused outright rather than resolved
last-wins, since that resolution is what turns any smuggling primitive
into a full bypass and no legitimate client sends two.

Three regression tests, including a guard assertion that the untruncated
buffer really was accepted before, so the test cannot quietly stop
testing the bypass.

Found by adversarial review, verified by extracting the real authorize()
and running the attack against it.

148 Rust tests, frontend build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:53:58 -07:00
shadow-testandClaude Opus 5 d42b741337 Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>:latest whenever it
existed, and container_needs_recreation compared the container's live
image against the triple-c.image label — which create_container wrote
from the same image it created from. A tautology that could never fire.
The only escape was Reset, which calls remove_project_volumes and
destroys the login, skills and transcripts.

Measured consequences on this host: real projects are missing socat (so
the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not
work), plus Mission Control and triple-c-sso-refresh, and sit 61
packages behind the base including ca-certificates, openssl and curl.

Detection. create_container now writes triple-c.base-image-id (the image
ID, not RepoDigests, which local-built and custom images do not have)
and triple-c.create-image. container_needs_recreation takes the expected
create-image and compares against the latter, so the check means
something. base-image-id is deliberately NOT compared: a base bump would
otherwise silently recreate from the snapshot, consuming the "you should
migrate" signal without migrating. Staleness is surfaced, never acted on
automatically.

Migration keeps the volumes. /home/claude and ~/.claude are volumes and
the image's copy is seed-only — permanently masked after first mount —
so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH
keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only
root-level state is rebuilt: apt packages are replayed against the new
base rather than copied, so no stale libc is dragged forward, and
/usr/local, /opt and the non-bind-mounted parts of /workspace are copied
verbatim with tar --skip-old-files so they can never clobber a newer
base binary.

docker diff is not used: on a snapshot-derived container it reports only
changes since the last commit. Raw image-vs-image diffing is filtered
through dpkg ownership because it otherwise lies — 8,677 raw path
differences on a real project reduced to 2 genuinely user-authored
files, both loose /workspace-root files.

Crash safety. snapshot:latest keeps pointing at the old image until the
final commit, so any crash before it self-heals on next start. Later
crashes are caught by reconcile_project_statuses. The rollback pin is a
docker tag: 0.057s and 0 bytes. Rollback restores the system layer only
— volumes are never touched — and the UI says so rather than implying a
time machine.

Fixes an infinite recreation loop shipped with the MCP removal. docker
commit propagates labels to the image, so a container created from a
snapshot inherited its non-empty triple-c.mcp-fingerprint and the
one-shot shim recreated it again on every start, forever. Lineage labels
are now always written explicitly.

Documents the second, separate bug this uncovered: Dockerfile changes
under /home/claude never reach an existing project, migration or not,
because the volume masks them. Anything that must stay upgradable
belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh.

145 Rust tests, 227 frontend tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:19:12 -07:00
shadow-testandClaude Opus 5 cc5f691677 Add llama.cpp backend, model gateway, URL relay and browser view
Four features, plus a latent bug fix.

llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.

Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.

Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.

URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.

Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.

188 frontend tests, 107 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:55:28 -07:00
shadow-testandClaude Opus 5 7d00390e1f Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.

Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.

The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.

Root-cause fix, and the more serious half of this commit:
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 line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.

`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.

121 frontend tests, 44 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:20:52 -07:00
shadow-testandClaude Opus 5 cf3b021c72 Confirm before Reset, and rewrite the docs for the new UI
Reset is destructive in a way its name does not advertise:
rebuild_project_container deletes both project volumes, so it wipes the
claude login, anything installed in the container, and every saved
session transcript. It was a single unconfirmed click in the overflow
menu, while the comparably destructive Remove already confirmed. Adds
ConfirmResetModal, which names each loss and says explicitly that the
host-side mounted folders are untouched.

Docs: the user guides still described the pre-Project-Home UI. Sixteen
factually wrong statements corrected, including "expand the Config
panel" (six sites), the actions table (Reset and Remove are in an
overflow menu, Files is a tab), a progress modal that no longer exists,
a double-click-to-rename gesture ProjectRow never had, the Full
Permissions boolean, an incomplete reserved-env list, and the claim in
TECHNICAL.md that OAuth tokens survive a Reset. Both layout diagrams and
the project tree were rebuilt from the filesystem.

New sections cover permission modes with the exact CLI mapping, Project
Home, Sessions, capability tiles, Automation, shared authentication, the
Auth Bridge and its security posture, and keyboard shortcuts.

Known gap recorded rather than papered over: the Automation tab manages
existing scheduled tasks but cannot create them — no add command is
registered — so task creation remains `triple-c-scheduler add` in the
terminal.

87 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:02:35 -07:00
shadow-testandClaude Opus 5 d95ba54a69 Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.

Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.

Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.

Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.

84 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:49:03 -07:00
shadow-testandClaude Opus 5 01a2f6aec8 Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.

Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.

Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.

Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.

This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.

Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.

The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.

Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:35:42 -07:00
shadow-testandClaude Opus 5 f68d10d5c2 Add DESIGN-REVIEW.md and ROADMAP.md
DESIGN-REVIEW.md is Fable's review of the v0.3.0 UI: token gaps and three
WCAG AA contrast failures, the modal/accessibility audit, and an IA
proposal that promotes the project from a sidebar card to a tabbed
main-area view.

ROADMAP.md covers Claude Code feature coverage — the five settings.json
keys currently surfaced, the gaps worth closing, the ones deliberately
skipped, the authentication handoff design, and phase sequencing.

Also published as an artifact for easier reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:56:26 -07:00
shadow-testandClaude Opus 5 0ac4e5030c Add permission modes and container introspection backend
Permission modes: replaces the binary full_permissions flag with a
PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is
defined once in PermissionMode::cli_args() and used by the terminal, the
web terminal, and the scheduler:
  Plan        -> --permission-mode plan
  Default     -> (no flag)
  AcceptEdits -> --permission-mode acceptEdits
  Bypass      -> --dangerously-skip-permissions
Choices verified against `claude --permission-mode` on 2.1.226.

full_permissions is retained and effective_permission_mode() falls back
to it, so existing projects.json needs no migration.

Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip-
permissions` unconditionally, ignoring the project's setting entirely.
It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the
container, added to the reserved env blocklist, propagated through the
entrypoint's cron env filter, and tracked by a new
triple-c.permission-mode label so a change forces recreation.

Introspection: new commands/inspect_commands.rs exposes read-only views
into the container over docker exec — Claude sessions (parsed from
~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills,
agents, commands, hooks, plugins, natively-configured MCP servers), and
the triple-c-scheduler task list, logs and notifications.

Task/session ids are validated against a strict allowlist and every
parameterized call runs as a bare argv vector via bollard, so no shell
is involved. Stopped containers return empty results rather than errors.

No UI yet; that lands with the Project Home view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:34 -07:00
shadow-testandClaude Opus 5 d0bb631d4d Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit.

Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and
commands/mcp_commands.rs, the McpStore on AppState, the four IPC
handlers, Project::enabled_mcp_servers, build_mcp_servers_json(),
compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the
mcp-fingerprint label, and the whole MCP container lifecycle.
create_container() and container_needs_recreation() lose their
mcp_servers/network_name parameters.

Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into
~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist.

Security: the Docker socket is no longer auto-mounted for stdio+Docker
MCP servers — it now mounts only when allow_docker_access is set.

Migration: old containers were created with
network_mode=triple-c-net-<projectId> and refuse to start once that
network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with
label-driven, best-effort removal of leftover MCP containers and the
per-project network, called on both delete and recreate.
container_needs_recreation() now forces a rebuild for any container
carrying a non-empty triple-c.mcp-fingerprint label or attached to a
triple-c-net-* network, moving it onto the default bridge. Both can be
dropped a release later.

Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a
short note pointing at Claude Code's native `claude mcp` / `/mcp` /
.mcp.json instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:31:18 -07:00
shadow-testandClaude Opus 5 657c61939f Remove MCP tab and per-project MCP UI from frontend
Claude Code manages MCP natively now (`claude mcp add/list/remove`,
`.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant.

Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and
rail icon, the per-project enable checkboxes on ProjectCard, the
mcpServers slice of the Zustand store, the four IPC wrappers, and the
McpServer/McpTransportType types.

Rust backend is untouched in this commit; the commands simply become
unreachable. Backend removal and the legacy container/network cleanup
follow separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:04:25 -07:00
jknapp 401e28a658 Merge pull request 'Fix conflicting --global/--file flags in entrypoint git config' (#12) from fix/entrypoint-gitconfig-flags into main
Build Container / build-container (push) Successful in 2m47s
2026-07-27 14:16:17 +00:00
shadow-testandClaude Opus 5 7c39e3cf11 Fix conflicting --global/--file flags in entrypoint git config
Build Container / build-container (pull_request) Successful in 10m15s
git rejects `--global` and `--file` together ("error: only one config
file at a time"), so the credential helper and user.name/user.email
were never written — /home/claude/.gitconfig was left nonexistent and
containers had no git identity or HTTPS token helper.

Drop `--global` and keep `--file /home/claude/.gitconfig`, which is the
intended target: the entrypoint runs as root at that point, so
`--global` would have resolved to /root/.gitconfig, and the existing
`chown claude:claude /home/claude/.gitconfig` already assumes the
--file path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:05:06 -07:00
jknapp ccdfc52dce Merge pull request 'docs: document terminal layout & StatusBar control gotchas' (#11) from docs/terminal-layout-gotchas into main 2026-07-19 16:26:02 +00:00
shadow-testandClaude Opus 4.8 2e661979ea docs: document terminal layout & StatusBar control gotchas
Capture the non-obvious implementation gotchas from PR #7
(terminal-layout-statusbar) in TECHNICAL.md: wrapper-vs-host xterm
padding, global StatusBar controls, recordingSessionIdRef transcript
pinning, active-only Jump-to-Current state, and the Zustand
object-merge rule for publishing action callbacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:12:54 -07:00
jknapp 59d89bcd1b Merge pull request 'Rename backup archive root so extraction dir mode isn't clobbered' (#10) from fix/backup-root-dir-mode into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m35s
Build App / build-linux (push) Successful in 5m1s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-07-01 13:35:02 +00:00
shadow-testandClaude Opus 4.8 26adccce5b Rename backup archive root so extraction dir mode isn't clobbered
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / build-windows (pull_request) Successful in 4m26s
Build App / build-linux (pull_request) Successful in 5m0s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The transform used `s,^\./,workspace/,`, which rewrites the workspace
*contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a
bare `./`. That `./` entry carries the source root's mode/mtime, and on
extraction tar stamps them onto the extraction directory itself.

Match the leading `.` instead (`s,^\.,workspace,`) so the root member is
renamed `./` -> `workspace`, giving the archive a proper `workspace/`
directory entry and no bare `./`. The extraction directory is left
untouched. Contents, hidden files, excludes, symlink targets and the
`flags=rh` hardlink handling are unchanged.

Verified in-container: archive top level is exactly `workspace/` +
`home-claude/`, no `./` member, node_modules excluded, extraction into a
0755 dir leaves it 0755, workspace/.git preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:33:02 -07:00
jknapp 876ba8a8fc Merge pull request 'Nest workspace under workspace/ in project backup' (#9) from fix/backup-workspace-nesting into main
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m36s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-07-01 13:23:08 +00:00
shadow-testandClaude Opus 4.8 5cd528a4ef Use flags=rh so intra-workspace hardlinks survive the transform
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 4m24s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:

  tar: workspace/file.txt: Cannot hard link to './hard_link':
       No such file or directory

`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:22:14 -07:00
shadow-testandClaude Opus 4.8 c3fc029b1d Nest workspace under workspace/ in project backup
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m17s
Build App / build-linux (pull_request) Successful in 5m7s
Build App / build-windows (pull_request) Successful in 5m9s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The backup archive placed the workspace at the archive root (`./...`)
while the sanitized home config sat under `home-claude/`. On extraction
the workspace files scattered loose into the extraction directory and
only `home-claude/` showed up as a distinct folder, so the backup read
as "config only, workspace missing" — and some archive viewers didn't
surface the root-level entries at all.

Add `--transform='flags=r;s,^\./,workspace/,'` so the workspace nests
under `workspace/`, parallel to `home-claude/`. `flags=r` scopes the
rewrite to member names only, leaving symlink targets (relative and
absolute) intact. Excludes still match the pre-transform names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:18:08 -07:00
jknapp dc253e8da0 Merge pull request 'Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop' (#8) from fix/backend-switch-aws-creds into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 34s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m14s
Build App / build-linux (push) Successful in 6m22s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
2026-06-30 22:20:00 +00:00
shadow-testandClaude Opus 4.8 3e2e3f231b Third review pass: fix tar TOCTOU + transient backup status
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 31s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m9s
Build App / build-linux (pull_request) Successful in 5m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- F2: upload_host_file_to_container now reads the dropped file into a Vec
  inside the blocking task and sizes the tar entry from those exact bytes,
  rather than stat-then-stream where a file changing size between the
  stat and the read could desync the tar header and silently corrupt the
  archive. Still runs off the async worker; memory stays bounded by the
  256 MiB drop cap.
- F4: the "Backup saved" confirmation now auto-clears after 8s (guarded
  against clobbering a newer status message) instead of lingering in the
  project card's status line indefinitely.

F1 (claimed AWS CLI regression from empty-env neutralization) was a false
positive: verified against aws-cli 2.35 that an empty AWS_ACCESS_KEY_ID is
treated as absent and botocore falls through to ~/.aws/credentials (the
call reached AWS and returned InvalidClientTokenId for the file's key, not
PartialCredentialsError). No change needed.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:16:42 -07:00
shadow-testandClaude Opus 4.8 01a8f5c503 Apply remaining review findings (L-e, L-f)
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m30s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m6s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L-e: route terminal file drops purely by a bounds hit-test instead of
  the `active` flag. Inactive panes are display:none (zero-size rect) so
  they never match; a zero-size guard makes that explicit. Correct for
  the current tabbed layout and future-proof for split panes, where a
  drop on a visible-but-unfocused pane previously matched no handler.
- L-f: stream the dropped file straight into the upload tar inside a
  blocking task (new exec::upload_host_file_to_container) instead of
  reading the whole file into a Vec and then re-packing it. Peak memory
  drops from ~2x to ~1x the file size, and the synchronous file IO no
  longer runs on the async worker.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:05:25 -07:00
shadow-testandClaude Opus 4.8 0945e21eb1 Address second review pass: fix start-time race + minor cleanups
Build App / compute-version (pull_request) Successful in 9s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m11s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- M1 (race): don't mount the host AWS dir for static-credential Bedrock.
  sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in
  that mode, and mounting /tmp/.host-aws let the entrypoint's
  `rm -rf ~/.aws; cp -a` race that write at startup (only when a global
  aws_config_path was also set). Static keys + AWS_REGION env are
  self-sufficient and don't need the host config, so skipping the mount
  removes the dual-writer entirely.
- L-a: exit codes are now read via wait_for_exec_exit(), which polls
  inspect_exec until the exec reports finished, so a non-zero tar/cred
  exit isn't missed by reading exit_code too early. The backup only fails
  on a definitively non-zero code (falls back to the empty-output check
  if undeterminable).
- L-b: fixed two comments that referenced the old
  write_bedrock_static_credentials name (now sync_bedrock_credentials).
- L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is
  actually present, avoiding a needless jq reformat on every non-SSO
  start.
- L-d: backup script traps EXIT to remove its mktemp staging dir even
  when tar fails, so failed backups don't accumulate temp dirs (with the
  sanitized config copy) in the container.

L-e (drop routing) is a non-issue: the layout is tabbed, so only one
terminal pane is ever visible; the active-guard routing is correct.

Verified the race fix, trap cleanup, grep guard, and exit-code polling.
cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:59:48 -07:00
shadow-testandClaude Opus 4.8 d65872dc94 Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
  credentials) now also clears a stale ~/.aws/credentials when the
  project no longer uses static-credential Bedrock, so static keys don't
  linger unused in the persistent home volume after switching backends.
  Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
  also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
  that the archive includes MCP/config which may contain MCP-embedded
  API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
  tokio::fs::remove_file) instead of blocking std::fs between awaits;
  dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
  /tmp/triple-c-drops instead of relying on Docker's tar extractor to
  create the parent dir.

Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:37:33 -07:00
shadow-testandClaude Opus 4.8 edf0698774 Address PR review: backup correctness/security + drop hardening
Build App / compute-version (pull_request) Successful in 8s
Build Container / build-container (pull_request) Successful in 51s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m52s
Build App / build-linux (pull_request) Successful in 6m5s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fixes from the code review of this branch:

- Backup requires a running container (it runs via `docker exec`, which
  can't run on a stopped one). Removed the misleading "Backup" button
  from the stopped-project actions, added an explicit running check with
  a clear error, and corrected the doc comment. (H1)
- jq sanitization fallback no longer leaks secrets: if ~/.claude.json
  can't be parsed, the backup substitutes an empty object and warns to
  stderr instead of copying the raw file (which held primaryApiKey /
  oauthAccount). Verified the raw key never reaches the archive. (H2)
- Dropped-file paths typed into the terminal are now always single-quoted
  (with '\'' escaping), not only when they contain whitespace — a name
  like `foo$(whoami).txt` was previously sent raw into the shell. (M2)
- write_bedrock_static_credentials checks the exec exit code via the new
  exec_oneshot_env_status and fails loudly on a write/chmod error instead
  of silently reporting success. exec_oneshot keeps its
  ignore-exit-code behavior so list_container_files is unaffected. (M4)
- Backup removes a partial/truncated archive on any stream error and
  treats a non-zero tar exit code as failure (a truncated gzip was
  previously reported as success). (L1)
- Dropped files are capped at 256 MiB to avoid ballooning host RAM
  (the file is read fully into memory then re-tarred). (M3)
- Stopped excluding .git/objects from the backup so git history,
  including unpushed commits, is preserved faithfully. (L3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:28:00 -07:00
shadow-testandClaude Opus 4.8 84e0bdf7b4 Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.

Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.

Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:11:09 -07:00
shadow-testandClaude Opus 4.8 10e689eaa6 Include sanitized home config in project backup
Extends download_container_backup to also capture the container's home
config so MCP servers, settings, and skills set up directly via Claude
Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes
that a Reset wipes) survive a backup/restore cycle.

Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is
filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses
(mcpServers and settings are kept), and ~/.claude/.credentials.json (the
OAuth tokens) is omitted. Staged config is archived under home-claude/ in
the tarball. Verified on the Ubuntu/jq container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:04:19 -07:00
shadow-testandClaude Opus 4.8 d07dcdfea9 Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:48:04 -07:00
shadow-testandClaude Opus 4.8 424ab04ca8 Fix stale AWS/Bedrock auth carrying over on backend switch
When a project switched backends (e.g. Bedrock -> Anthropic), the
recreated container kept authenticating against Bedrock, and SSO kept
firing after switching away. Three root causes, all fixed:

1. Recreation builds the new container from a `docker commit` snapshot.
   commit always bakes the previous container's full ENV into the image
   (an empty commit Config does NOT strip it, and the commit API cannot
   remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the
   new container. Fix: create_container now explicitly clears every
   managed auth key the active backend does not set (MANAGED_AUTH_KEYS),
   so create-time env overrides the stale baked-in values.

2. awsAuthRefresh was written into ~/.claude.json (persisted home
   volume) and never removed, so Claude Code kept invoking
   triple-c-sso-refresh after switching to a non-SSO backend. Fix:
   entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD
   is unset, idempotent both ways.

3. Static/session AWS creds were baked into Config.Env at create time,
   so a stop/start kept stale creds and rotated keys never refreshed
   without a full recreation. Fix: static creds are no longer injected
   as env vars; write_bedrock_static_credentials() writes
   ~/.aws/credentials (0600, secrets via exec env not argv) on every
   start, and removes a stale ~/.aws/config left from a prior profile/SSO
   session. Static creds also dropped from the bedrock fingerprint so a
   key rotation refreshes in place instead of forcing recreation.

Adds exec_oneshot_env() for env-carrying one-shot execs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:34:43 -07:00
jknapp 1716fb5c82 Merge pull request 'Fix xterm clipping; move mic + Jump to Current into status bar' (#7) from terminal-layout-statusbar into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #7
2026-06-29 15:23:35 +00:00
shadow-testandClaude Opus 4.8 da7b7b9bd5 Address review: pin STT transcript, clear stale scroll state
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 3m49s
Build App / build-linux (pull_request) Successful in 4m48s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Follow-up to PR review on terminal-layout-statusbar:

- [Major] Pin STT transcripts to the originating terminal. The single
  useSTT instance is bound to the live active session, which can change
  mid-recording. Capture the session id at recording start in a ref and
  inject the transcript there instead of the live sessionId, so text
  always lands in the terminal where recording began.
- [Minor] Clear the status-bar scroll state when the active terminal
  unmounts, and null out termRef on dispose, so scrollActiveToBottom
  can't point at a disposed terminal. Tab switches don't unmount, so
  this only fires when the active session is actually closed.
- [Nit] Fix the terminal padding comment to match the symmetric value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:33:27 -07:00
shadow-testandClaude Opus 4.8 3d2d979197 Fix xterm clipping; move mic + Jump to Current into status bar
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m19s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / build-windows (pull_request) Successful in 7m34s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Terminal layout fixes for the xterm pane:

- Stop the terminal grid from clipping its rightmost column / bottom
  row. The padding was on the element xterm mounts into, which the
  FitAddon measures; the grid overhang got clipped. Padding now lives on
  a wrapper and the xterm host fills it with no padding.
- Move the STT mic from a floating bottom-left overlay into the status
  bar (far right). A single useSTT instance bound to the active session
  now lives in App; Ctrl+Shift+M routes through the store.
- Move "Jump to Current" from a floating terminal overlay into the
  status bar. The active TerminalView surfaces its scroll state and
  scroll action via the store.
- Tighten terminal padding (was 8/12/48/16) now that nothing floats over
  it, so the terminal claims as much area as possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:26:05 -07:00
jknapp de2752557d Merge pull request 'Fix Windows release upload: idempotent get-or-create + fail-loud' (#6) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
Reviewed-on: #6
2026-06-24 16:28:58 +00:00
jknapp 9221c25474 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m35s
Build App / build-windows (pull_request) Successful in 3m51s
Build App / build-linux (pull_request) Successful in 5m20s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 16:28:45 +00:00
shadow-testandClaude Opus 4.8 997e1ab3a9 Fix Windows release upload: idempotent get-or-create + fail-loud
Build App / compute-version (pull_request) Successful in 9s
Build App / build-macos (pull_request) Successful in 2m36s
Build App / build-windows (pull_request) Successful in 2m50s
Build App / build-linux (pull_request) Successful in 6m26s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.

Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:55 -07:00
jknapp 2be6b9d9a8 Merge pull request 'UX improvements: Claude auto-update on container start + file-list scroll' (#5) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m3s
Build App / build-linux (push) Successful in 4m58s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Failing after 18m35s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #5
2026-06-24 01:51:49 +00:00
jknapp ebfe1f11a6 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m1s
Build App / build-windows (pull_request) Failing after 14m55s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 01:50:36 +00:00
shadow-testandClaude Opus 4.8 7f8102985e Update Claude on container start; harden file-list scroll
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 7m34s
Build App / build-linux (pull_request) Successful in 5m2s
Build App / build-windows (pull_request) Failing after 16m56s
Build App / build-macos (pull_request) Successful in 2m37s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Add a time-bounded `claude update` to entrypoint.sh that runs as the
claude user before the container is marked ready, so every terminal
session launches the latest CLI. Non-fatal and capped at 120s so an
offline/slow network never blocks container readiness; PATH covers both
~/.claude/bin and ~/.local/bin install locations.

Add flex-shrink-0 to the FileManagerModal header/footer so a long file
list can't squeeze them and the scroll region stays robust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:24:37 -07:00
jknapp 1a5fbd6be4 Merge pull request 'UX: collapsible sidebar, settings accordion, global backend defaults, tab rename' (#4) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m1s
Build App / build-macos (push) Successful in 2m30s
Build App / build-windows (push) Successful in 2m50s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #4
2026-05-24 16:39:34 +00:00
shadow-testandClaude Opus 4.7 2fa6abeae0 Allow renaming terminal tabs (persisted per project)
Build App / compute-version (pull_request) Successful in 3s
Build App / build-windows (pull_request) Successful in 5m33s
Build Container / build-container (pull_request) Successful in 7m58s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / build-macos (pull_request) Successful in 2m39s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Right-click a tab (or double-click) to rename. Renamed labels show
as "ProjectName: CustomName" and are stored in the project's
renamed_session_names map. The entry is cleared on tab close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:48 -07:00
shadow-testandClaude Opus 4.7 5b1c801cf1 Add global backend defaults with runtime fallback
New fields: GlobalAwsSettings.default_model_id, plus
GlobalOllamaSettings and GlobalOpenAiCompatibleSettings (base_url +
default_model_id each). When a per-project base_url or model_id is
blank, the container env vars and config fingerprints fall back to
the global value. Container recreation is triggered whenever the
resolved value changes, so editing a global default updates existing
projects on next start.

UI: added the new fields to AwsSettings and two new global settings
components, slotted into the Backends accordion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:06 -07:00
shadow-testandClaude Opus 4.7 9b78b4bc62 Group Settings panel into accordion sections
Multiple-open accordion with per-section state persisted to
localStorage. Sections: General, Backends, Container, Git/SSH,
Tools, Updates. General is open by default; the rest are collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:42:18 -07:00
shadow-testandClaude Opus 4.7 7acc8b8d39 Add collapsible sidebar with icon rail
Persist collapsed state in localStorage. When collapsed, render a
narrow rail with Projects/MCP/Settings icon buttons that expand the
sidebar to that view on click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:40:54 -07:00
shadow-testandClaude Opus 4.7 7840bddbb4 Sync bundled mission-control to upstream 15fbc94
Pulls in 15 upstream commits since the April 3 bundling snapshot
(msieurthenardier/mission-control). Notable changes:

- agentic-workflow rewritten as the "fast" variant: per-leg design and
  implement, single review and commit across the whole flight
- New Skill-Project Boundary section: skills no longer read or write
  project-owned artifacts by literal heading
- routine-maintenance scoped to post-mission only; adds state-machine
  reachability and cache freshness audits
- Test metrics capture threaded through debrief, maintenance, and flight
- Crew prompts no longer carry skill-required instructions; SKILL.md is
  the protocol
- Worktree git strategy removed; standardized on {target-project}
- Jira artifact template removed upstream

Local URL correction in init-project/README.md preserved
(anthropics/flight-control -> msieurthenardier/mission-control).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:10:32 -07:00
shadow-testandClaude Opus 4.7 4588bdf40c Make macOS release upload idempotent across re-runs
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m42s
Build App / build-linux (push) Successful in 8m54s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Previous fix only addressed the network flake; a re-run after any
upload failure still tripped over the leftover release record. The
naive POST /releases got 409 from Gitea, the grep-pipe parser yielded
an empty RELEASE_ID, and pipefail aborted with an opaque exit 1.

Now:
- Look up the release by tag first; reuse on 200, create on 404, fail
  loudly on anything else.
- Validate RELEASE_ID is non-empty and surface the response body if
  parsing fails.
- Before uploading each asset, check whether the release already has
  an asset with that name (from a partial prior run) and DELETE it so
  the POST is replace-not-conflict.
- Set -euo pipefail explicitly so the script's failure modes are
  predictable rather than dependent on the runner's default flags.

Network hardening from the previous commit (HTTP/1.1, retries, -f) is
preserved. Linux and Windows blocks unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:25 -07:00
shadow-testandClaude Opus 4.7 b607cf3681 Harden macOS release upload against curl exit 92
Build App / compute-version (push) Successful in 3s
Build App / build-windows (push) Successful in 4m5s
Build App / build-linux (push) Successful in 9m53s
Build App / build-macos (push) Failing after 2m30s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
macOS upload has been intermittently failing with curl exit 92
("HTTP/2 stream not closed cleanly") for several releases (v0.3.12,
v0.3.10, v0.3.1 all landed with empty asset arrays despite the per-tag
release record being created). It is not a size issue — Linux uploads
the 81MB AppImage on the same Gitea instance without trouble while the
Mac dmg is only 13.6MB.

Adds `--http1.1` to sidestep HTTP/2 stream multiplexing flakes on the
macOS runner, `-f` so HTTP errors no longer fail silently under `-s`,
and `--retry 5 --retry-all-errors --retry-delay 5 --max-time 600` to
absorb transient drops. Linux and Windows blocks unchanged; an inline
note in the YAML calls out where to mirror this if those start
failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:28:08 -07:00
shadow-testandClaude Opus 4.7 21a85dc977 Bump @tauri-apps/api and @tauri-apps/cli to 2.11.0 in package-lock
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 3m24s
Build App / build-windows (push) Successful in 4m9s
Build App / build-linux (push) Successful in 7m20s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Mac/Windows release builds failed the Tauri version-mismatch check:
tauri (2.11.0) vs @tauri-apps/api (2.10.1). The Linux fix only updated
the Rust lockfile; the npm lockfile was still at 2.10.x. Both lockfiles
now resolve to 2.11.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:09:22 -07:00
shadow-testandClaude Opus 4.7 272eb28863 Bump tauri Rust crate to 2.11.0 to match @tauri-apps/api
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Failing after 6s
Build App / build-windows (push) Failing after 24s
Build App / build-linux (push) Successful in 6m52s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
CI's pre-build version check failed: tauri (2.10.2) vs @tauri-apps/api
(2.11.0). Both the Cargo.toml and package.json caret-pin to 2, so this is
purely a lockfile resolution fix — `cargo update -p tauri --precise
2.11.0` brings the Rust side up to match. Schema regeneration is included
since the gen/schemas/ output is keyed to the Tauri version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:32:02 -07:00
jknapp 1ef6efca9f Merge pull request 'feature/docker-install-helper' (#3) from feature/docker-install-helper into main
Build App / compute-version (push) Successful in 2s
Build Container / build-container (push) Successful in 1m1s
Build App / build-linux (push) Failing after 2m0s
Build App / build-macos (push) Failing after 3m27s
Build App / build-windows (push) Successful in 4m8s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #3
2026-05-01 20:01:58 +00:00
shadow-testandClaude Opus 4.7 5974347913 Add per-project sandbox mode and Bedrock service-tier
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 8m1s
Build Container / build-container (pull_request) Successful in 8m11s
Build App / build-linux (pull_request) Failing after 1m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Sandbox mode: new per-project toggle that turns on Claude Code's bash
sandbox inside the container. Adds `bubblewrap` and `socat` to the
Dockerfile (the two Linux deps required by the sandbox), and emits a
managed `sandbox` block into `~/.claude/settings.json` via the existing
CLAUDE_CODE_SETTINGS_JSON entrypoint merge:

- `enabled` mirrors the Triple-C toggle and is always emitted, so the
  entrypoint's recursive jq merge clears any prior on-state from the
  persisted named volume — Triple-C is authoritative.
- `enableWeakerNestedSandbox: true` because we run inside Docker without
  privileged user namespaces.
- `allowUnsandboxedCommands: false` to disable the `dangerouslyDisableSandbox`
  escape hatch — opting into the sandbox shouldn't come with a runtime
  bypass.

When sandbox is on, a SANDBOX_INSTRUCTIONS section is appended to
CLAUDE_INSTRUCTIONS so Claude can guide users through allowing extra
paths/domains, excluding `docker *`/`watchman *` from the sandbox, and
the rule that `sandbox.enabled` is owned by Triple-C. The Claude-Code
settings fingerprint includes sandbox state (only when on, to avoid
spuriously flagging existing containers for recreation on upgrade).

Bedrock service tier: new optional field on the per-project Bedrock
config. When set, exported as ANTHROPIC_BEDROCK_SERVICE_TIER (added in
Claude Code 2.1.122) and included in the Bedrock fingerprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:54 -07:00
shadow-testandClaude Opus 4.7 805f815876 Regenerate Tauri ACL schemas after dialog plugin update
Picks up the deprecation notes on dialog `ask`/`confirm` permissions
(now aliased to `allow-message`/`deny-message` and slated for removal
in Tauri v3). No behavior change — generated artifacts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:57:22 -07:00
shadow-testandClaude Opus 4.7 5360f22b65 Make preview build workflow manual-only
Trigger is workflow_dispatch exclusively so builds happen only when
explicitly requested from the Actions UI, not on every branch push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:22:11 -07:00
shadow-testandClaude Opus 4.7 0316234329 Add preview build workflow for non-main branches
Build App (Preview) / compute-version (push) Successful in 2s
Build App (Preview) / build-macos (push) Failing after 2m28s
Build App (Preview) / build-windows (push) Failing after 4m29s
Build App (Preview) / build-linux (push) Failing after 8m4s
Mirrors build-app.yml's three-platform matrix (Linux/macOS/Windows)
but uploads the bundles as workflow artifacts instead of creating
Gitea releases or syncing to GitHub, so feature branches can be
smoke-tested without cluttering the release streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:21:17 -07:00
shadow-testandClaude Opus 4.7 ee68cc820c Add Docker install helper for first-run setup
When Docker isn't detected on startup, surface a dialog offering a
one-click install (pkexec + get.docker.com on Linux, brew cask on
macOS, winget on Windows) with a graceful fallback to manual steps
and a link to official documentation. Install output streams back
to the UI via a tauri event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:18:46 -07:00
shadow-testandClaude Opus 4.7 7f6655fbcf Trim whitespace on terminal copy by default, keep raw copy on Ctrl+Shift+Alt+C and right-click menu
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m42s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 17s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:58:56 -07:00
shadow-testandClaude Opus 4.7 b907ad0239 Add breathing room to terminal bottom-left so STT button clears Claude Code status
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 4m20s
Build App / build-linux (push) Successful in 5m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:27:30 -07:00
shadow-testandClaude Opus 4.7 de1d809de5 Update Flight Control reference URL to mission-control repo
Build Container / build-container (push) Successful in 1m13s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:29 -07:00
shadow-testandClaude Opus 4.7 3c7852544b Fix TUI fullscreen mode cutting off Claude Code status line
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 16s
Add bottom padding to terminal containers so FitAddon proposes one
fewer row, leaving visible space below Claude Code's mode indicator.
Previously the bottom status line (e.g. "bypass permissions on") was
clipped against the container edge in fullscreen TUI mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:44:10 -07:00
shadow-testandClaude Opus 4.6 ddf44d97e5 Fix Docker build: manual NodeSource setup + retry loops on all apt-get updates
Build Container / build-container (push) Successful in 41m2s
The previous fix wasn't enough: the NodeSource setup_22.x script runs its
own internal `apt-get update` without retries. When that hit the Ubuntu
mirror-sync issue (stale Packages.gz with mismatched hash), the script
silently bailed without configuring the NodeSource repo. The next
`apt-get install -y nodejs` then installed Ubuntu's default nodejs 18,
which ships without npm, breaking the `npm install -g pnpm` step.

Changes:
- Replace the `curl ... | bash -` NodeSource setup with manual GPG key +
  repo file configuration, giving us direct control over apt-get update
  retries.
- Add the same 5-attempt retry loop (with 10s sleep and lists cleanup)
  to the Python 3 and Docker CLI steps, since both also do an
  apt-get update and would hit the same failure mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:50:42 -07:00
236 changed files with 43297 additions and 5538 deletions
+578
View File
@@ -0,0 +1,578 @@
name: Build App (Preview)
# Builds the Tauri app for branches other than main and publishes the bundles as
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
# sync.
#
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
# a push that breaks any of them fails here. 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:
# Every push to an open PR: this *is* the branch's build check — it compiles
# Linux, macOS and Windows — and publishing the result costs nothing extra
# once they are built. build-app.yml deliberately no longer runs on PRs.
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app-preview.yml"
workflow_dispatch:
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
sha: ${{ steps.version.outputs.SHA }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute preview version
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
SHORT_SHA=$(git rev-parse --short HEAD)
# From the checkout, not from `gitea.sha`: on a pull_request event
# that variable can be the merge ref, which is not the commit anyone
# is testing and not something to hang a tag on.
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# The patch number 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 "Computed preview version: ${VERSION}"
# One release, created once. The three build jobs run concurrently, so
# get-or-create in each of them would race on the same tag: whoever loses gets
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
# still reported success. Creating it in a job they all depend on removes the
# race rather than handling it.
create-release:
runs-on: ubuntu-latest
needs: [compute-version]
outputs:
release_id: ${{ steps.release.outputs.RELEASE_ID }}
tag: ${{ steps.release.outputs.TAG }}
steps:
- name: Create the preview release
id: release
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
SHA: ${{ needs.compute-version.outputs.sha }}
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
run: |
set -euo pipefail
TAG="preview-${VERSION##*.}"
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
# Idempotent: re-dispatching the same commit must update the existing
# release rather than fail on the duplicate tag.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200) echo "Release ${TAG} already exists, reusing" ;;
404)
echo "Creating release ${TAG}"
# prerelease: true keeps it off "latest" — this is a branch build,
# not something anyone should install by accident.
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "Release ${TAG} is id ${RELEASE_ID}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version, create-release]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
# Tauri / Cargo require a strict semver; strip the preview suffix for
# the bundle version but keep it in the artifact filename.
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
libssl-dev \
libxdo-dev \
patchelf \
pkg-config \
build-essential \
curl \
wget \
file \
xdg-utils
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file.
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
# retry/http1.1 hardening that build-app.yml learned from real macOS
# upload failures (curl exit 92 and exit 28 mid-stream).
- name: Upload Linux bundles to the preview release
shell: bash
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
set -euo pipefail
shopt -s nullglob
files=(artifacts/*)
if [ ${#files[@]} -eq 0 ]; then
echo "No Linux bundles were produced" >&2
exit 1
fi
for file in "${files[@]}"; do
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Replacing existing asset ${filename}"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-macos:
runs-on: macos-latest
needs: [compute-version, create-release]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
if [ "$NODE_MAJOR" -lt 22 ]; then
NEED_INSTALL=true
fi
else
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
brew install node@22
brew link --overwrite node@22
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app (universal)
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file.
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
# retry/http1.1 hardening that build-app.yml learned from real macOS
# upload failures (curl exit 92 and exit 28 mid-stream).
- name: Upload macOS bundles to the preview release
shell: bash
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
set -euo pipefail
shopt -s nullglob
files=(artifacts/*)
if [ ${#files[@]} -eq 0 ]; then
echo "No macOS bundles were produced" >&2
exit 1
fi
for file in "${files[@]}"; do
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Replacing existing asset ${filename}"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-windows:
runs-on: windows-latest
needs: [compute-version, create-release]
defaults:
run:
shell: cmd
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
shell: powershell
run: |
$raw = "${{ needs.compute-version.outputs.version }}"
$version = $raw.Split('-')[0]
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install Rust stable
run: |
where rustup >nul 2>&1 && (
rustup update stable
rustup default stable
) || (
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
rustup-init.exe -y --default-toolchain stable
del rustup-init.exe
)
- name: Install Node.js
run: |
where node >nul 2>&1 && (
node --version
) || (
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
msiexec /i node-install.msi /quiet /norestart
del node-install.msi
)
- name: Verify tools
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
rustc --version
cargo --version
node --version
npm --version
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
- name: Fix npm platform detection
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm config set os win32
npm config list
- name: Install frontend dependencies
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
if exist node_modules rmdir /s /q node_modules
npm ci
- name: Build frontend
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build
- name: Build Tauri app
working-directory: ./app
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
- name: Collect artifacts
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
mkdir artifacts
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
dir artifacts\
# PowerShell, because this job's default shell is cmd. Same
# delete-then-upload shape as the other two.
- name: Upload Windows bundles to the preview release
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
$files = @(Get-ChildItem -File -Path artifacts\*)
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
foreach ($file in $files) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Replacing existing asset $name"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
# Keep the preview list short. Runs after the builds and only if all three
# succeeded: a half-published run must not be what evicts a good older build.
prune-previews:
runs-on: ubuntu-latest
needs: [create-release, build-linux, build-macos, build-windows]
steps:
- name: Delete all but the newest preview releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
run: |
set -euo pipefail
curl -fsS -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
# Newest first by creation time, `preview-` only, and never the one
# this run just published — a clock skew must not delete it.
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
import json, sys
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
previews = [r for r in json.load(open("releases.json"))
if r["tag_name"].startswith("preview-")]
previews.sort(key=lambda r: r["created_at"], reverse=True)
for r in previews[keep:]:
if r["tag_name"] != keep_tag:
print(r["id"], r["tag_name"])
PY
)
if [ -z "${DOOMED}" ]; then
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
exit 0
fi
echo "${DOOMED}" | while read -r ID TAG; do
[ -z "${ID}" ] && continue
echo "Deleting ${TAG} (id ${ID})"
# Best effort: a preview someone deleted by hand mid-run is not a
# reason to fail a build that otherwise succeeded.
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
done
+301 -37
View File
@@ -7,14 +7,14 @@ on:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
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:
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
@@ -39,16 +39,55 @@ jobs:
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
# `|| true` so an empty grep result doesn't fail the step under pipefail.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
# The patch number is **one past the highest patch already used**, and
# never a distance.
#
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
# not a counter at all: it measures how far HEAD has drifted from
# whichever tag sorts highest, and that resets to zero every time a
# tag is cut. The published history is the proof — each of these is
# exactly what the old formula returned at the time:
#
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
#
# Reusing a version is worse than failing to publish one: the macOS
# and Windows steps replace assets in place, so a duplicate silently
# rewrote a release that had been public for three days. Monotonic
# numbering is what stops that at the source.
#
# Suffixed tags count too. `create-tag` is skipped when any platform
# job fails, so a run can publish v0.4.7-mac and never create the
# plain v0.4.7 — reading only unsuffixed tags would then hand the
# same number out twice.
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
| sort -n | tail -1 || true)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
# A re-run of a commit that already released must not mint a new
# version just because its own tag now exists.
EXISTING=$(git tag --points-at HEAD \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
| sort -n | tail -1 || true)
if [ -n "$EXISTING" ]; then
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
PATCH="${EXISTING}"
elif [ -n "$HIGHEST" ]; then
echo "Highest patch already used on this line: ${HIGHEST}"
PATCH=$((HIGHEST + 1))
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
# A minor line nobody has tagged yet is a *new* line, and a new line
# starts at .0 — that is what "we are moving to 0.4.x" means. The
# old fallback here counted every commit in the repository, which
# would have made the first 0.4 build 0.4.234.
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
PATCH=0
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
@@ -161,21 +200,70 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \
# Idempotent get-or-create, matching build-macos. This step used to
# POST /releases unconditionally: against a tag that already existed
# Gitea answered 409, the grep below found no id, and the run died
# with a bare "exitcode '1'" and not one line of output explaining
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
# pipefail so a failure cannot be stepped over.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Creating release ${TAG}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
;;
*)
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
cat release.json >&2
exit 1
;;
esac
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
if [ -z "${RELEASE_ID}" ]; then
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Replace-not-conflict, so a retry after a partial upload succeeds.
# Versions are monotonic now (see compute-version), so this can only
# ever be replacing an asset from a failed run of this same commit —
# never one belonging to an already-published version.
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -s -X POST \
curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
@@ -264,21 +352,72 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Create release
curl -s -X POST \
# Idempotent get-or-create. macOS upload has historically failed
# mid-stream (curl exit 92, exit 28), leaving the release record
# with empty assets. A naive POST /releases on the next run hits
# 409 from Gitea for the duplicate tag, the JSON parse below
# then yields an empty RELEASE_ID, and pipefail aborts with an
# opaque exit 1. Look the release up by tag first; create only
# if it doesn't exist; reuse the existing id otherwise.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Release ${TAG} not found, creating"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Upload each artifact. If an asset with the same name already
# exists on the release (left over from a partial prior run),
# delete it first so the upload is replace-not-conflict.
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
# the macOS runner has hit, retries with backoff for transient
# drops, and -f so HTTP errors stop being silently swallowed.
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -s -X POST \
curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
@@ -306,6 +445,77 @@ jobs:
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install 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
run: |
where rustup >nul 2>&1 && (
@@ -365,32 +575,86 @@ jobs:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
run: |
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
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
mkdir artifacts
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
dir artifacts\
- name: Upload to Gitea release
if: gitea.event_name == 'push'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set "TAG=v${{ needs.compute-version.outputs.version }}-win"
echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found
echo Release ID: %RELEASE_ID%
for %%f in (artifacts\*) do (
echo Uploading %%~nxf...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
)
$ErrorActionPreference = "Stop"
$tag = "v$env:VERSION-win"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
# Idempotent get-or-create. The old cmd-batch version swallowed
# curl errors and parsed the release id with findstr, so a 409 on
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
# a malformed .../releases//assets URL while the step still reported
# success. Look the release up by tag first; create only on 404.
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
Write-Host "Release $tag already exists, reusing"
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
-ContentType "application/json" -Body $body -Uri "$api/releases"
} else {
throw
}
}
$releaseId = $release.id
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
Write-Host "Release ID: $releaseId"
# Upload each artifact. Delete any same-named asset left over from a
# partial prior run first, so the upload replaces rather than 409s.
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Deleting existing asset $name (id $($dupe.id))"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
create-tag:
runs-on: ubuntu-latest
+284 -11
View File
@@ -56,38 +56,278 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### 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`)
- **`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/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
### UI conventions
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
previously had ~12 hand-rolled modals; they were consolidated deliberately.
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
Use `--text-disabled` rather than `disabled:opacity-50`.
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
why tab-moving takes Shift.
### Backend Structure (`app/src-tauri/src/`)
- **`commands/`** — Tauri command handlers (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:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `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
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume
it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach
it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`).
`GatewayBinding` derives the bind address and the advertised `base_url` together so they
cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls —
in front of a container config holding a billed provider key. It also **always** sets a
LiteLLM `master_key`, since LiteLLM without one accepts any key.
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
crash-recovery state machine. See "Base-image migration" below.
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated.
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `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`
### 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`
- **`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
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
@@ -95,7 +335,20 @@ Per-project, independently configured:
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
- **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
@@ -108,8 +361,28 @@ Per-project, independently configured:
- 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 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`.
- **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
## Testing
+337
View File
@@ -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.
+768 -184
View File
File diff suppressed because it is too large Load Diff
+516 -83
View File
@@ -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 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
@@ -13,46 +39,183 @@ 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)
│ (25% w, │
│ responsive│
│ Sidebar │ Main Content
│ (25% w, │ · Project Home views, or
│ responsive│ · terminal views (xterm.js)
│ 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
1. **Create**: New container created with bind mounts, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
clears those after a recreation and after a migration is accepted. It only ever removes images that
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
Docker itself refuses while any container — including a stopped project's — is still built from the
image.
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
### Base-Image Migration
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
way out; Reset is the destructive one. `docker/migration.rs` owns it.
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
`container_needs_recreation`. Comparing it there would recreate every project *from its own
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
"stale".
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
useless here — on a snapshot-derived container it only reports changes since the last commit.
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
skills, transcripts and scheduler tasks simply re-attach.
- **What does not**: `/etc` is reported but never copied — the old lineage has
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
on a recreate, but a migration builds from the base and the apt replay hands back an empty
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
report.
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
failure before that self-heals — the next start just recreates from the old snapshot. After the
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
`$HOME` during a migrated session survives it.
### Mounts
| Target in Container | Source | Type | Notes |
|---|---|---|---|
| `/workspace` | Project directory | Bind | Read-write |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
### 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.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
every start — so it survives recreation, migration and Reset.
> **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)
@@ -60,26 +223,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.
### 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 |
|------|--------------|---------------------|
| 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 |
## Models and Authentication
**Key behaviors**:
- **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
- **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
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
- **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`
### Authentication Modes
Each project can independently use one of:
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
> they get first-class backends of their own rather than going through a translation layer.
#### Model alias variables
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
Triple-C therefore sets all four:
| Variable | Value |
|---|---|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
A local server usually serves exactly one model, so pointing every alias at it is the right
default. If you run a second, smaller model for cheap background work, set **Background model**
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
### Model Gateway (LiteLLM sibling container)
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
…), an optional API base override, the models to serve, and the host port (default `4000`). A
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
lifecycle, including auto-start with the app.
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
**always** set, because LiteLLM without one accepts any key.
### Shared Claude Authentication Token
Rather than running `claude login` in every container, `claude setup-token` can be run once
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
anything resembling an `sk-ant-` secret.
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
Rotation is tracked with a random id (not a hash of the token) mirrored into the
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
forces a container recreation on the next start; that is when a container picks the token up or has
it cleared.
## Bridges to the Host
### URL Relay (host browser)
There is no browser and no display inside the container, so any CLI that wants to open a web page
`gh auth login`, `aws sso login`, `gcloud auth login`, `az login`, vendor CLIs, `xdg-open`,
Python's `webbrowser` — simply fails. The URL relay forwards the *request* to the host, where the
user's real browser is. Nothing is rendered or forwarded from the container; only the URL travels.
It complements the Auth Bridge below: the relay gets the login page open, the bridge lets the
callback land.
**Transport — an OSC escape sequence, following `osc52-clipboard`.** `container/triple-c-open`
writes
```
ESC ] 7777 ; open ; <base64(url)> BEL
```
to **`/dev/tty`**, and `TerminalView.tsx` picks it up with `term.parser.registerOscHandler(7777, …)`.
`/dev/tty` rather than stdout is the whole point: the shim usually runs as a grandchild of
something that captures its children's output (Claude Code invoking `gh auth login` as a tool
call), so a printed sentinel line — the `###TRIPLE_C_SSO_REFRESH###` approach — would be swallowed
by the intermediate process and never reach the terminal. A control sequence on the controlling
terminal always arrives, and is invisible to terminals that don't know it. Base64 keeps a `;`,
`BEL` or `ESC` inside the URL from breaking out of the sequence.
**Container side**`container/triple-c-open`, installed as `xdg-open`, `sensible-browser`,
`www-browser`, `x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open`, and exported as
`$BROWSER`. Ubuntu 24.04 ships a real `/usr/bin/sensible-browser` (from `sensible-utils`), so that
one is `dpkg-divert`ed rather than merely shadowed by a `/usr/local/bin` symlink; `www-browser` and
`x-www-browser` are registered through `update-alternatives` and pinned with `--set`, because
`sensible-browser` probes them by absolute path and because a later `apt install firefox` must not
be able to steal them. `xdg-open` is diverted pre-emptively so installing `xdg-utils` inside the
container cannot displace the relay. `BROWSER` is an image-level `ENV` — terminal sessions are
separate `docker exec`s and never see what the entrypoint exported — and the entrypoint also
forwards it into the scheduler's cron environment file.
**No terminal attached** (cron-driven scheduled tasks, or a plain `docker exec` from outside
Triple-C): there is no handshake and nothing to wait for, so the shim never blocks. The write to
`/dev/tty` fails, and it prints the URL in plain text on its own line and exits 0 — which lands in
the scheduler task log where a human can still act on it.
**Security posture — the container is the untrusted side.** `app/src/lib/urlRelay.ts` validates
before anything reaches `openUrl`: `http:`/`https:` only (`file:`, `javascript:`, `data:` and every
registered protocol handler rejected), no embedded credentials, no control characters or
whitespace, length-capped, and returned WHATWG-normalized so the prompt shows exactly what will
open. Nothing opens automatically — the user confirms in the existing `UrlToast`, and prompts are
rate-limited (5 per 10 s, repeats of the same URL collapsed) so a loop in the container cannot bury
the UI.
**Web terminal** — deliberately *not* a copy of the desktop behaviour. The browser there belongs to
a remote viewer, possibly on a phone across a tunnel, so `terminal.html` renders the relayed URL as
a tap-to-open link banner with the same scheme allowlist and rate limit, and opens nothing by
itself. The OSC handler is registered regardless so the sequence is consumed rather than painted as
garbage.
### Auth Bridge
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
browser's redirect to reach it. `auth_bridge/` closes that gap:
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
binds are deliberately ignored (that is the port-mappings feature's job).
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
IPs are not routable from the host on Docker Desktop.
- Ports already covered by the project's port mappings are skipped, and a host port that is already
in use is reported as a conflict rather than fought over.
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
recreates the container. The poller stops on its own when the container stops.
**Security posture:** the host side binds loopback only. Everything reachable through it is an
unauthenticated service inside the container, so widening those addresses would publish container
internals to the LAN. Nothing else on the network can reach a bridged port.
### Browser View
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
per project.
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
another monitor or pinned on top while the main window is used for work. No capability lists that
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
top-level document rather than a frame — the token gate is what protects the port in both cases.
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
iframe while popped out, and both viewers can drive the browser.
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
listener is *in* the container, so a container-side browser closes the loop with no host round
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
continuously and each event costs a container exec.
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules`
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
for global installs and the wrapper ships no type definitions to read a version from. Installing
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
recreation *and* migration.
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
once Playwright is present — not a setup route.
## 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
@@ -99,75 +459,122 @@ The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stor
### 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
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
- **Port**: Default `9876` (configurable)
- **Input device**: Selectable in Settings when the host exposes more than one microphone
- **Language**: Optional language hint for transcription
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
### Docker Socket Path
The socket path is OS-aware:
- **Linux/macOS**: `/var/run/docker.sock`
- **Windows**: `//./pipe/docker_engine`
Users can override this in Settings via the global `docker_socket_path` option.
## Key Files
### Frontend — layout and projects
| File | Purpose |
|---|---|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons |
| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) |
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
### Frontend — Project Home
| File | Purpose |
|---|---|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: 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/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
| `app/src/components/terminal/SttButton.tsx` | Mic button overlay with on-demand container start |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
| `app/src/hooks/useFileManager.ts` | File 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-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
### Backend (Rust)
| File | Purpose |
|---|---|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
| `app/src-tauri/src/docker/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/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/mcp_commands.rs` | MCP server CRUD 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/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
| `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 |
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
### Container and packaging
| File | Purpose |
|---|---|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, 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 |
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
## CSS / Styling Notes
@@ -180,8 +587,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
**Base**: Ubuntu 24.04
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `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)
+250
View File
@@ -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 23
- **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.
+280 -42
View File
@@ -2,7 +2,7 @@
## 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.
- **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)
**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) │
│ │
│ /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) │
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
│ /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:
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.
2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
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` 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.
4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist.
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.
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**: `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
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 |
| **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 |
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
| **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
@@ -195,10 +362,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
| Data | Storage | Location |
|------|---------|----------|
| 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 |
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
| 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.
@@ -220,98 +389,160 @@ The `TerminalView` component works around this with a **URL accumulator**:
triple-c/
├── README.md # Architecture overview
├── 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
├── 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
│ ├── 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)
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
│ ├── 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/
│ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
│ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image
│ ├── sync-release.yml # Mirror releases to GitHub
── backfill-releases.yml # Bulk copy releases to GitHub
── backfill-releases.yml # Bulk copy releases to GitHub
│ └── cleanup-releases.yml # Prune old releases
└── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss
├── vite.config.ts # Vite bundler config
├── vitest.config.ts # Vitest (jsdom) config
├── index.html # HTML entry point
├── src/ # React frontend
│ ├── main.tsx # React DOM root
│ ├── App.tsx # Top-level layout
│ ├── index.css # CSS variables, dark theme, scrollbars
│ ├── App.tsx # Top-level layout + welcome screen
│ ├── index.css # CSS variables, dark theme, focus ring, scrollbars
│ ├── store/
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
│ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts)
│ ├── hooks/
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
│ │ ├── useDocker.ts # Docker status, image build/pull
│ │ ├── useFileManager.ts # File manager operations
│ │ ├── useMcpServers.ts # MCP server CRUD
│ │ ├── useFileManager.ts # File browser operations
│ │ ├── 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
│ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state
│ │ ├── useSettings.ts # App settings
│ │ ├── useSTT.ts # Speech-to-text recording and container control
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
│ │ ├── useUpdates.ts # App update checking
│ │ └── useVoice.ts # Voice mode audio capture
│ ├── lib/
│ │ ├── types.ts # TypeScript interfaces matching Rust models
│ │ ├── 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
│ └── components/
│ ├── layout/ # Sidebar, TopBar, StatusBar
│ ├── mcp/ # McpPanel, McpServerCard
├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
│ # FileManagerModal, ContainerProgressModal, modals
│ ├── DockerInstallDialog.tsx # First-run Docker setup
│ ├── layout/ # TopBar, MainTabs (the unified tab strip),
# Sidebar, StatusBar, HelpDialog
├── 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,
│ │ # WebTerminalSettings, UpdateDialog
└── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
│ │ # OllamaSettings, LlamaCppSettings,
│ # OpenAiCompatibleSettings,
│ │ # SharedAuthSettings, ClaudeAuthModal,
│ │ # WebTerminalSettings, SttSettings,
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
│ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu,
│ │ # SttButton, UrlToast, trimSelection
│ └── ui/ # Shared primitives: Modal, Button, Toggle, Field,
│ # SegmentedControl, StatusIndicator, SaveIndicator,
│ # OverflowMenu, ToastHost, Tooltip, AccordionSection
└── src-tauri/ # Rust backend
├── Cargo.toml # Rust dependencies
├── tauri.conf.json # Tauri app configuration
├── build.rs # Tauri build script
├── capabilities/
│ └── default.json # Tauri v2 permission grants
│ └── default.json # Tauri v2 plugin permission grants
└── src/
├── lib.rs # App builder, plugin + command registration
├── main.rs # Entry point
├── logging.rs # Log configuration
├── commands/ # Tauri command handlers
│ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
│ ├── aws_commands.rs # AWS profile/region discovery
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File manager (list/download/upload)
│ ├── mcp_commands.rs # MCP server CRUD
│ ├── project_commands.rs # Start/stop/rebuild containers
│ ├── file_commands.rs # File browser (list/download/upload)
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
│ ├── install_helper_commands.rs # Guided Docker installation
│ ├── project_commands.rs # Start/stop/rebuild/backup containers
│ ├── settings_commands.rs # Settings CRUD
│ ├── stt_commands.rs # STT start/stop/transcribe
│ ├── terminal_commands.rs # Terminal I/O, resize
│ ├── update_commands.rs # App update checking
│ └── web_terminal_commands.rs # Web terminal start/stop/status
├── auth_bridge/ # Host-side loopback callback bridge
│ ├── mod.rs # Per-project poller, status, lifecycle
│ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering
│ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API
├── web_terminal/ # Remote terminal access
│ ├── mod.rs # Module root
│ ├── server.rs # Axum HTTP+WS server lifecycle
│ ├── ws_handler.rs # WebSocket connection handler
│ └── terminal.html # Embedded xterm.js web UI
├── install_helper/ # Docker installation assistance
│ ├── mod.rs # Install orchestration
│ └── platform.rs # Per-OS install strategies
├── docker/ # Docker API layer
│ ├── client.rs # bollard singleton connection
│ ├── container.rs # Create, start, stop, remove, fingerprinting
├── exec.rs # PTY exec sessions with bidirectional streaming
│ ├── container.rs # Create/start/stop/remove, labels, recreation checks,
│ # remove_project_volumes, snapshot commit
│ ├── exec.rs # create_attached_exec() — the single attached-exec path
│ ├── 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
│ ├── project.rs # Project, Backend, BedrockConfig
│ ├── mcp_server.rs # MCP server configuration
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
│ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, …
│ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal)
│ ├── container_config.rs # Image name resolution
│ └── update_info.rs # Update metadata
└── storage/ # Persistence
├── projects_store.rs # JSON file with atomic writes
├── mcp_store.rs # MCP server persistence
├── 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 |
| `dirs` | 6.x | Cross-platform app data directory paths |
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
| `log` / `fern` | 0.4 / 0.7 | Date-based file logging |
| `include_dir` | 0.7 | Embeds the container build context in the binary |
| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads |
| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` |
| `sha2` | 0.10 | Settings fingerprints |
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
| `tower-http` | 0.6 | CORS middleware for web terminal |
| `base64` | 0.22 | Terminal data encoding over WebSocket |
@@ -357,6 +593,8 @@ triple-c/
| `zustand` | 5.x | Lightweight state management |
| `tailwindcss` | 4.x | Utility-first CSS framework |
| `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
+1 -1
View File
@@ -1 +1 @@
0.3
0.4
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Triple-C</title>
</head>
+52 -52
View File
@@ -1,12 +1,12 @@
{
"name": "triple-c",
"version": "0.2.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "triple-c",
"version": "0.2.0",
"version": "0.4.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
@@ -1757,9 +1757,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -1767,9 +1767,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.0.tgz",
"integrity": "sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -1783,23 +1783,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
"@tauri-apps/cli-darwin-arm64": "2.11.0",
"@tauri-apps/cli-darwin-x64": "2.11.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.0",
"@tauri-apps/cli-linux-arm64-musl": "2.11.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-musl": "2.11.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.0",
"@tauri-apps/cli-win32-x64-msvc": "2.11.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.0.tgz",
"integrity": "sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==",
"cpu": [
"arm64"
],
@@ -1814,9 +1814,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.0.tgz",
"integrity": "sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==",
"cpu": [
"x64"
],
@@ -1831,9 +1831,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.0.tgz",
"integrity": "sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==",
"cpu": [
"arm"
],
@@ -1848,9 +1848,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.0.tgz",
"integrity": "sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==",
"cpu": [
"arm64"
],
@@ -1865,9 +1865,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.0.tgz",
"integrity": "sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==",
"cpu": [
"arm64"
],
@@ -1882,9 +1882,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.0.tgz",
"integrity": "sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==",
"cpu": [
"riscv64"
],
@@ -1899,9 +1899,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.0.tgz",
"integrity": "sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==",
"cpu": [
"x64"
],
@@ -1916,9 +1916,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.0.tgz",
"integrity": "sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==",
"cpu": [
"x64"
],
@@ -1933,9 +1933,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.0.tgz",
"integrity": "sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==",
"cpu": [
"arm64"
],
@@ -1950,9 +1950,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.0.tgz",
"integrity": "sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==",
"cpu": [
"ia32"
],
@@ -1967,9 +1967,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.0.tgz",
"integrity": "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==",
"cpu": [
"x64"
],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "triple-c",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+13
View File
@@ -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

+340 -116
View File
@@ -280,6 +280,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -617,9 +632,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.24.0"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
@@ -699,6 +714,19 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "cssparser"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
@@ -711,14 +739,20 @@ dependencies = [
[[package]]
name = "ctor"
version = "0.2.9"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
dependencies = [
"quote",
"syn 2.0.117",
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "darling"
version = "0.20.11"
@@ -795,6 +829,17 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbus"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -849,6 +894,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.117",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -880,12 +946,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -932,6 +992,21 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dom_query"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser 0.36.0",
"foldhash 0.2.0",
"html5ever 0.38.0",
"precomputed-hash",
"selectors 0.36.1",
"tendril 0.5.0",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -956,6 +1031,21 @@ dependencies = [
"dtoa",
]
[[package]]
name = "dtor"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1143,6 +1233,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1614,7 +1710,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -1655,10 +1751,20 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [
"log",
"mac",
"markup5ever",
"markup5ever 0.14.1",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever 0.38.0",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -2158,18 +2264,12 @@ version = "0.8.8-speedreader"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [
"cssparser",
"html5ever",
"cssparser 0.29.6",
"html5ever 0.29.1",
"indexmap 2.13.0",
"selectors",
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2206,6 +2306,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@@ -2296,9 +2405,20 @@ dependencies = [
"log",
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache",
"string_cache_codegen",
"tendril",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
"tendril 0.4.3",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms",
]
[[package]]
@@ -2388,9 +2508,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.17.1"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2401,10 +2521,10 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png 0.17.16",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2422,12 +2542,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -2533,17 +2647,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2563,7 +2669,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -2602,6 +2707,16 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -2614,19 +2729,6 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2666,16 +2768,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2688,17 +2780,6 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2706,8 +2787,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.0",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
@@ -2723,8 +2823,6 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -2857,6 +2955,17 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.8.0"
@@ -2877,6 +2986,16 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.8.0"
@@ -2907,6 +3026,16 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.10.0"
@@ -2934,6 +3063,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_shared"
version = "0.8.0"
@@ -2961,6 +3103,15 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -3751,14 +3902,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
dependencies = [
"bitflags 1.3.2",
"cssparser",
"derive_more",
"cssparser 0.29.6",
"derive_more 0.99.20",
"fxhash",
"log",
"phf 0.8.0",
"phf_codegen 0.8.0",
"precomputed-hash",
"servo_arc",
"servo_arc 0.2.0",
"smallvec",
]
[[package]]
name = "selectors"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.0",
"cssparser 0.36.0",
"derive_more 2.1.1",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"precomputed-hash",
"rustc-hash",
"servo_arc 0.4.3",
"smallvec",
]
@@ -3953,6 +4123,15 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -4098,6 +4277,18 @@ dependencies = [
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
@@ -4110,6 +4301,18 @@ dependencies = [
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4190,35 +4393,35 @@ dependencies = [
[[package]]
name = "tao"
version = "0.34.5"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dbus",
"dispatch2",
"dlopen2",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"lazy_static",
"libc",
"log",
"ndk",
"ndk-context",
"ndk-sys",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-ui-kit",
"once_cell",
"parking_lot",
"percent-encoding",
"raw-window-handle",
"scopeguard",
"tao-macros",
"unicode-segmentation",
"url",
@@ -4258,9 +4461,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.10.2"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129"
checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66"
dependencies = [
"anyhow",
"bytes",
@@ -4310,9 +4513,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.5.5"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74"
checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988"
dependencies = [
"anyhow",
"cargo_toml",
@@ -4326,15 +4529,14 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
[[package]]
name = "tauri-codegen"
version = "2.5.4"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3"
checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -4359,9 +4561,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.4"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59"
checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -4470,9 +4672,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651"
checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95"
dependencies = [
"cookie",
"dpi",
@@ -4495,9 +4697,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314"
checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117"
dependencies = [
"gtk",
"http",
@@ -4505,7 +4707,6 @@ dependencies = [
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"once_cell",
"percent-encoding",
"raw-window-handle",
@@ -4522,17 +4723,18 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.8.2"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e"
checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7"
dependencies = [
"anyhow",
"brotli",
"cargo_metadata",
"ctor",
"dom_query",
"dunce",
"glob",
"html5ever",
"html5ever 0.29.1",
"http",
"infer",
"json-patch",
@@ -4540,6 +4742,7 @@ dependencies = [
"log",
"memchr",
"phf 0.11.3",
"plist",
"proc-macro2",
"quote",
"regex",
@@ -4593,6 +4796,16 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4928,9 +5141,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.21.3"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
dependencies = [
"crossbeam-channel",
"dirs",
@@ -4942,15 +5155,15 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.17.16",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "triple-c"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -5353,6 +5566,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@@ -6000,24 +6225,23 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wry"
version = "0.54.2"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a"
checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429"
dependencies = [
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dom_query",
"dpi",
"dunce",
"gdkx11",
"gtk",
"html5ever",
"http",
"javascriptcore-rs",
"jni",
"kuchikiki",
"libc",
"ndk",
"objc2",
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "triple-c"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
[lib]
@@ -38,6 +38,11 @@ base64 = "0.22"
rand = "0.9"
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]
tauri-build = { version = "2", features = [] }
File diff suppressed because one or more lines are too long
+64 -16
View File
@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string",
"const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
},
{
"description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string",
"const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
},
{
"description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
},
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
},
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{
"description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
},
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
+64 -16
View File
@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string",
"const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
},
{
"description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string",
"const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
},
{
"description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
},
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
},
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{
"description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
},
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+702
View File
@@ -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));
}
}
+302
View File
@@ -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");
}
}
+270
View File
@@ -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);
}
+346
View File
@@ -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)
}
+698
View File
@@ -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));
}
}
File diff suppressed because it is too large Load Diff
+863
View File
@@ -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());
}
}
+375
View File
@@ -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());
}
}
+338
View File
@@ -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);
}
}
+793
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+195 -1
View File
@@ -1,4 +1,5 @@
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::State;
@@ -151,6 +152,199 @@ pub async fn download_container_file(
Ok(())
}
/// Create a `.tar.gz` backup of the container and stream it to a host file.
/// The archive contains:
/// - the workspace (default /workspace), minus regenerable build artifacts
/// (node_modules, target), under `workspace/`, and
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native
/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
/// `.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,
/// including unpushed commits. Build + gzip happen inside the container so a
/// large workspace isn't streamed in full. The container must be RUNNING (the
/// backup runs via `docker exec`). Returns the number of bytes written.
#[tauri::command]
pub async fn download_container_backup(
project_id: String,
host_path: String,
container_path: Option<String>,
state: State<'_, AppState>,
) -> Result<u64, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
let docker = get_docker()?;
// The backup runs inside the container via `docker exec`, which requires it
// to be running. Fail with a clear message rather than a raw Docker error.
let running = docker
.inspect_container(container_id, None)
.await
.ok()
.and_then(|info| info.state)
.and_then(|s| s.running)
.unwrap_or(false);
if !running {
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
}
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Stage a sanitized home config, then tar+gzip workspace + staged config to
// stdout. mktemp/jq output go nowhere near stdout, so the only thing the
// exec emits on stdout is the archive itself. --ignore-failed-read keeps a
// transient unreadable file from aborting the whole backup. If jq can't
// parse ~/.claude.json we substitute an empty object — never the raw file —
// 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
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/home-claude"
if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
printf '{}' > "$STAGE/home-claude/.claude.json"
fi
fi
if [ -d "$HOME/.claude" ]; then
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
rm -f "$STAGE/home-claude/.claude/.credentials.json"
fi
tar czf - --ignore-failed-read \
--exclude='*/node_modules' --exclude='*/target' \
--transform='flags=rh;s,^\.,workspace,' \
-C "$TC_BACKUP_SRC" . \
-C "$STAGE" home-claude"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: Some(vec![
"HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path),
]),
user: Some("claude".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
};
use tokio::io::AsyncWriteExt;
let file = tokio::fs::File::create(&host_path)
.await
.map_err(|e| format!("Failed to create backup file: {}", e))?;
let mut writer = tokio::io::BufWriter::new(file);
let mut total: u64 = 0;
let mut stderr_text = String::new();
let mut stream_err: Option<String> = None;
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write backup file: {}", e));
break;
}
total += message.len() as u64;
}
Ok(LogOutput::StdErr { message }) => {
stderr_text.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => {
stream_err = Some(format!("Backup stream error: {}", e));
break;
}
}
}
if stream_err.is_none() {
if let Err(e) = writer.flush().await {
stream_err = Some(format!("Failed to finalize backup file: {}", e));
}
}
drop(writer);
// The tar pipeline can abort mid-stream (producing a truncated archive) and
// still have sent bytes, so a non-zero exit must be treated as failure even
// when `total > 0`. Poll until the exec actually reports finished so the
// exit code is reliably populated; if it can't be determined we fall back to
// the `total == 0` check below.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!(
"Backup command failed (exit {}){}",
exit_code.unwrap_or(-1),
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if stream_err.is_none() && total == 0 {
stream_err = Some(format!(
"Backup produced no data{}",
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if let Some(err) = stream_err {
// Don't leave a partial/corrupt archive behind.
let _ = tokio::fs::remove_file(&host_path).await;
return Err(err);
}
log::info!(
"Wrote {} byte backup for project {} to {}",
total,
project_id,
host_path
);
Ok(total)
}
#[tauri::command]
pub async fn upload_file_to_container(
project_id: String,
@@ -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()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
use crate::install_helper::{self, InstallOptions};
#[tauri::command]
pub async fn detect_install_options() -> Result<InstallOptions, String> {
Ok(install_helper::detect_install_options())
}
#[tauri::command]
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
install_helper::platform::run_install(&app_handle).await
}
@@ -1,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)
}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,8 +1,13 @@
pub mod auth_bridge_commands;
pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod gateway_commands;
pub mod help_commands;
pub mod mcp_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod migration_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod stt_commands;
+263 -116
View File
@@ -2,11 +2,11 @@ use tauri::{Emitter, State};
use crate::commands::aws_commands;
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::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(
"container-progress",
serde_json::json!({
@@ -43,8 +43,50 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
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.
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")
.unwrap_or(None);
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]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list())
@@ -113,6 +142,15 @@ pub async fn remove_project(
project_id: String,
state: State<'_, AppState>,
) -> 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
if let Some(ref project) = state.projects_store.get(&project_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;
}
// Remove MCP containers and network
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);
}
}
if let Err(e) = docker::remove_project_network(&project.id).await {
log::warn!("Failed to remove project network for project {}: {}", project_id, e);
}
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
// containers first, then the per-project network they were attached to.
docker::remove_legacy_mcp_containers(&project.id).await;
docker::remove_legacy_project_network(&project.id).await;
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
@@ -152,10 +184,35 @@ pub async fn remove_project(
#[tauri::command]
pub async fn update_project(
project: Project,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
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]
@@ -164,6 +221,20 @@ pub async fn start_project_container(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> 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
.projects_store
.get(&project_id)
@@ -177,9 +248,6 @@ pub async fn start_project_container(
let settings = state.settings_store.get();
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
if project.backend == Backend::Bedrock {
let bedrock = project.bedrock_config.as_ref()
@@ -193,16 +261,30 @@ pub async fn start_project_container(
if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() {
return Err("Ollama base URL is required.".to_string());
if ollama.base_url.is_empty()
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
}
}
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 {
let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if oai_config.base_url.is_empty() {
return Err("OpenAI Compatible base URL is required.".to_string());
if oai_config.base_url.is_empty()
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
}
}
@@ -296,37 +378,18 @@ pub async fn start_project_container(
// AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone();
// Set up Docker network and MCP containers if needed
let network_name = if !docker_mcp.is_empty() {
// Pull any missing MCP Docker images before starting containers
for server in &docker_mcp {
if let Some(ref image) = server.docker_image {
if !docker::image_exists(image).await.unwrap_or(false) {
emit_progress(
&app_handle,
&project_id,
&format!("Pulling MCP image for '{}'...", server.name),
);
let image_clone = image.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)
// What we would create this container from *right now*: the project's
// snapshot when one exists, else the configured base. This is the value
// `container_needs_recreation` compares against the container's
// `triple-c.create-image` label — the check that replaced the old
// tautological one. It is resolved *before* the commit below, so it
// describes the pre-commit world the existing container was born into.
let snapshot_image = docker::get_snapshot_image_name(&project);
let expected_create_image =
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
snapshot_image.clone()
} else {
None
image_name.clone()
};
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
@@ -334,12 +397,17 @@ pub async fn start_project_container(
let needs_recreate = docker::container_needs_recreation(
&existing_id,
&project,
&expected_create_image,
&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(),
&enabled_mcp,
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.unwrap_or(false);
@@ -355,32 +423,45 @@ pub async fn start_project_container(
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
// Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project);
// Legacy MCP cleanup: the old container may have been attached to
// `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) {
snapshot_image
snapshot_image.clone()
} else {
image_name.clone()
};
let new_id = docker::create_container(
let new_id = create_container_for_project(
&project,
&settings,
&docker_socket,
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
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(),
&create_image,
&image_name,
docker::CreateExtras::default(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
// The commit above moved `:latest` and orphaned the image it
// used to point at; the container holding that image open was
// removed a few lines up, so now is when Docker will actually
// let it go. Detached because this is housekeeping and the
// project is already running — and it sweeps every orphan, not
// just this one, so recreations that happened before the sweep
// existed are cleaned up too.
tauri::async_runtime::spawn(async {
docker::sweep_orphaned_snapshots().await;
});
new_id
} else {
emit_progress(&app_handle, &project_id, "Starting container...");
@@ -391,36 +472,33 @@ pub async fn start_project_container(
// Container doesn't exist (first start, or Docker pruned it).
// Check for a snapshot image first — it preserves system-level
// changes (apt/pip/npm installs) from the previous session.
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
if expected_create_image == snapshot_image {
log::info!("Creating container from snapshot image for project {}", project.id);
snapshot_image
} else {
image_name.clone()
};
}
let create_image = expected_create_image.clone();
emit_progress(&app_handle, &project_id, "Creating container...");
let new_id = docker::create_container(
let new_id = create_container_for_project(
&project,
&settings,
&docker_socket,
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
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(),
&create_image,
&image_name,
docker::CreateExtras::default(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
new_id
};
// Sync Bedrock credentials on every start: refresh static/session creds
// so rotated keys are picked up without a full container recreation, and
// clear stale creds when the project no longer uses static-cred Bedrock.
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
}
Ok(container_id)
}.await;
@@ -436,6 +514,20 @@ pub async fn start_project_container(
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
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.status = ProjectStatus::Running;
Ok(project)
@@ -454,6 +546,9 @@ pub async fn stop_project_container(
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 {
// Close exec sessions for this project
emit_progress(&app_handle, &project_id, "Stopping container...");
@@ -464,15 +559,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)?;
Ok(())
}
@@ -483,11 +569,32 @@ pub async fn rebuild_project_container(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> 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
.projects_store
.get(&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
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
@@ -496,14 +603,6 @@ pub async fn rebuild_project_container(
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
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
@@ -520,14 +619,46 @@ pub async fn rebuild_project_container(
/// Called by the frontend after Docker is confirmed available. Projects
/// marked as Running whose containers are no longer running get reset
/// 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]
pub async fn reconcile_project_statuses(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<Project>, String> {
let projects = state.projects_store.list();
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;
}
@@ -543,6 +674,22 @@ pub async fn reconcile_project_statuses(
project.name,
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 {
log::info!(
"Project '{}' ({}) container is not running — setting to Stopped",
+254 -1
View File
@@ -1,6 +1,7 @@
use tauri::State;
use crate::docker;
use crate::models::gateway_settings::GatewaySettings;
use crate::models::AppSettings;
use crate::AppState;
@@ -14,7 +15,94 @@ pub async fn update_settings(
settings: AppSettings,
state: State<'_, AppState>,
) -> 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]
@@ -67,6 +155,78 @@ pub async fn detect_aws_config() -> Result<Option<String>, String> {
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]
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
let mut profiles = Vec::new();
@@ -115,3 +275,96 @@ pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
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)
.unwrap_or(false);
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
if project.full_permissions {
cmd.push("--dangerously-skip-permissions".to_string());
}
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
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())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
let claude_cmd = if project.full_permissions {
format!("exec claude --dangerously-skip-permissions{}", name_flag)
} else {
format!("exec claude{}", name_flag)
};
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
@@ -183,6 +185,55 @@ pub async fn paste_image_to_terminal(
.await
}
/// Copy a host file (e.g. dragged onto the terminal) into the container so
/// Claude Code can read it, and return the in-container path. Mirrors the
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
/// original name. Returns an error for paths that aren't readable regular files
/// (e.g. a dropped directory).
#[tauri::command]
pub async fn upload_host_file_to_terminal(
session_id: String,
host_path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
}
// Guard against ballooning host RAM: the file is packed into an in-memory
// tar before upload, so cap the size of a dropped file.
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
}
let base = std::path::Path::new(&host_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "dropped-file".to_string());
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
}
#[tauri::command]
pub async fn start_audio_bridge(
session_id: String,
+580
View File
@@ -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()));
}
}
File diff suppressed because it is too large Load Diff
+365 -40
View File
@@ -1,13 +1,94 @@
use bollard::container::UploadToContainerOptions;
use bollard::container::{LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt;
use futures_util::{Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex};
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 exec_id: String,
pub container_id: String,
@@ -80,37 +161,15 @@ impl ExecSessionManager {
where
F: Fn(Vec<u8>) + Send + 'static,
{
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("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 AttachedExec {
exec_id,
mut output,
mut input,
} = create_attached_exec(container_id, cmd, tty).await?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
match result {
StartExecResults::Attached { mut output, mut input } => {
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
@@ -151,11 +210,6 @@ impl ExecSessionManager {
}
}
});
}
StartExecResults::Detached => {
return Err("Exec started in detached mode".to_string());
}
}
let session = ExecSession {
exec_id,
@@ -279,8 +333,201 @@ impl ExecSessionManager {
}
}
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
/// read and packed into the tar inside a blocking task, so the synchronous IO
/// runs off the async worker. The tar's declared entry size is taken from the
/// bytes actually read (not a separate `stat`), so a file changing size between
/// a size check and the read can't desync the header and corrupt the archive.
/// Returns the in-container path (`/tmp/<dest_name>`).
pub async fn upload_host_file_to_container(
container_id: &str,
host_path: &str,
dest_name: &str,
) -> Result<String, String> {
let host_path = host_path.to_string();
let dest_name = dest_name.to_string();
let dest_for_blk = dest_name.clone();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
// Size comes from the bytes in hand, so header and payload can't disagree.
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, &dest_for_blk, &data[..])
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
Ok(tar_buf)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
let docker = get_docker()?;
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", 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.
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
exec_oneshot_env(container_id, cmd, Vec::new()).await
}
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
/// input is fully container-controlled and whose legitimate output is small.
pub async fn exec_oneshot_limited(
container_id: &str,
cmd: Vec<String>,
limit: usize,
) -> Result<String, String> {
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
.await
.map(|(output, _)| output)
}
/// Like `exec_oneshot`, but passes additional environment variables to the exec
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
/// by the same user / root) rather than in the process argv, so they are not
/// exposed via `ps`.
///
/// NOTE: the command's exit code is NOT checked — callers that need to know
/// whether the command succeeded should use `exec_oneshot_env_status`.
pub async fn exec_oneshot_env(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<String, String> {
exec_oneshot_env_status(container_id, cmd, env)
.await
.map(|(output, _exit_code)| output)
}
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
/// success). The returned string contains both stdout and stderr, interleaved
/// in arrival order, which is useful for surfacing failure detail.
pub async fn exec_oneshot_env_status(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
exec_oneshot_as(container_id, "claude", cmd, env).await
}
/// [`exec_oneshot_env_status`] with the user spelled out.
///
/// Base-image migration is the only caller that needs anything but `claude`:
/// `apt-get`, `npm -g` and the payload unpack all run as **root**. Note that
/// the container does grant `claude` passwordless sudo, but going through
/// `sudo` would put the whole command in `ps` output and add a second failure
/// mode to interpret, so the exec is simply created as root.
pub async fn exec_oneshot_as(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
}
async fn exec_oneshot_inner(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: usize,
) -> Result<(String, i64), String> {
let docker = get_docker()?;
let exec = docker
@@ -290,7 +537,8 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
user: Some("claude".to_string()),
env: if env.is_empty() { None } else { Some(env) },
user: Some(user.to_string()),
..Default::default()
},
)
@@ -302,17 +550,94 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let mut combined = String::new();
match result {
StartExecResults::Attached { mut output, .. } => {
let mut stdout = String::new();
while let Some(msg) = output.next().await {
match msg {
Ok(data) => stdout.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)),
}
}
Ok(stdout)
}
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
}
// The output stream draining doesn't strictly guarantee inspect_exec has the
// final exit_code populated yet, so poll until the exec reports finished.
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
Ok((combined, exit_code))
}
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
/// Returns `None` if the code can't be determined (inspect error, or the exec
/// doesn't report finished within ~1s — which shouldn't happen once its output
/// stream has drained).
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
let docker = get_docker().ok()?;
for _ in 0..40 {
match docker.inspect_exec(exec_id).await {
Ok(info) => {
if info.running != Some(true) {
// Finished: use the reported code (default 0 if somehow absent).
return Some(info.exit_code.unwrap_or(0));
}
}
Err(_) => return None,
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
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);
}
}
+949
View File
@@ -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);
}
}
+137
View File
@@ -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),
}
}
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,10 +1,15 @@
pub mod ca_certs;
pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub mod network;
pub mod gateway;
pub mod legacy_cleanup;
pub mod migration;
pub mod stt;
#[allow(unused_imports)]
pub use gateway::*;
#[allow(unused_imports)]
pub use stt::*;
#[allow(unused_imports)]
@@ -16,4 +21,9 @@ pub use image::*;
#[allow(unused_imports)]
pub use exec::*;
#[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.
-129
View File
@@ -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(())
}
+53
View File
@@ -0,0 +1,53 @@
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
// installed on the host and, when missing, offering to install it for the user.
//
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
// Windows. On every platform we also surface an official documentation URL so
// users without a recognised package manager can install manually.
use serde::{Deserialize, Serialize};
pub mod platform;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallOptions {
/// "linux" | "macos" | "windows" | "unknown"
pub os: String,
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
pub product_name: String,
/// Whether we can kick off a one-click install with what's on this machine.
pub can_auto_install: bool,
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
pub auto_install_method: Option<String>,
/// If auto-install isn't possible, a human-readable reason to show the user.
pub auto_install_blocker: Option<String>,
/// Official documentation URL for manual install.
pub docs_url: String,
/// Ordered manual install steps (plain text lines).
pub manual_steps: Vec<String>,
/// Notes to display after a successful auto-install (e.g. log out/back in).
pub post_install_notes: Vec<String>,
}
pub fn detect_install_options() -> InstallOptions {
if cfg!(target_os = "linux") {
platform::linux_options()
} else if cfg!(target_os = "macos") {
platform::macos_options()
} else if cfg!(target_os = "windows") {
platform::windows_options()
} else {
InstallOptions {
os: "unknown".into(),
product_name: "Docker".into(),
can_auto_install: false,
auto_install_method: None,
auto_install_blocker: Some("Unsupported operating system".into()),
docs_url: "https://docs.docker.com/get-docker/".into(),
manual_steps: vec![
"Visit the Docker documentation and follow the install guide for your OS.".into(),
],
post_install_notes: vec![],
}
}
}
@@ -0,0 +1,288 @@
use std::path::PathBuf;
use std::process::Stdio;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::InstallOptions;
const PROGRESS_EVENT: &str = "docker-install-progress";
fn which(cmd: &str) -> bool {
find_on_path(cmd).is_some()
}
/// Search PATH for an executable, plus a handful of well-known locations that
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
fn find_on_path(cmd: &str) -> Option<PathBuf> {
#[cfg(unix)]
let extra: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
];
#[cfg(windows)]
let extra: &[&str] = &[];
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path.split(sep).chain(extra.iter().copied()) {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
for ext in ["exe", "cmd", "bat"] {
let mut with_ext = candidate.clone();
with_ext.set_extension(ext);
if with_ext.is_file() {
return Some(with_ext);
}
}
}
}
for dir in extra {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let app_out = app.clone();
let out_task = tokio::spawn(async move {
if let Some(out) = stdout {
let mut lines = BufReader::new(out).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_out.emit(PROGRESS_EVENT, line);
}
}
});
let app_err = app.clone();
let err_task = tokio::spawn(async move {
if let Some(err) = stderr {
let mut lines = BufReader::new(err).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_err.emit(PROGRESS_EVENT, line);
}
}
});
let status = child
.wait()
.await
.map_err(|e| format!("install process failed: {}", e))?;
let _ = out_task.await;
let _ = err_task.await;
if !status.success() {
return Err(format!(
"installer exited with status {}",
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
));
}
Ok(())
}
// ─── Linux ───────────────────────────────────────────────────────────────────
pub fn linux_options() -> InstallOptions {
let has_pkexec = which("pkexec");
let has_curl = which("curl");
let (can_auto, blocker) = match (has_pkexec, has_curl) {
(true, true) => (true, None),
(false, _) => (
false,
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
),
(_, false) => (
false,
Some("curl not found — install curl or follow manual steps.".into()),
),
};
InstallOptions {
os: "linux".into(),
product_name: "Docker Engine".into(),
can_auto_install: can_auto,
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
auto_install_blocker: blocker,
docs_url: "https://docs.docker.com/engine/install/".into(),
manual_steps: vec![
"Open a terminal.".into(),
"Run: curl -fsSL https://get.docker.com | sh".into(),
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
"Log out and log back in for group changes to take effect.".into(),
],
post_install_notes: vec![
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
],
}
}
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
// Grab the current username so pkexec (which runs as root) can add the
// original invoking user to the docker group.
let invoking_user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.map_err(|_| "could not determine invoking username".to_string())?;
// Write a self-contained installer script to a temp file. Running the
// Docker convenience script then appending the user to the docker group
// and enabling the service.
let script = format!(
r#"#!/bin/sh
set -e
echo "[triple-c] Downloading Docker install script..."
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
echo "[triple-c] Running Docker install script (may take a few minutes)..."
sh /tmp/triple-c-get-docker.sh
rm -f /tmp/triple-c-get-docker.sh
echo "[triple-c] Adding {user} to docker group..."
usermod -aG docker "{user}" || true
echo "[triple-c] Enabling docker service..."
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
"#,
user = invoking_user
);
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
tokio::fs::write(&script_path, script)
.await
.map_err(|e| format!("failed to write install script: {}", e))?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Requesting administrator privileges via pkexec..."),
);
let child = Command::new("pkexec")
.arg("sh")
.arg(&script_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
let result = stream(app, child).await;
let _ = tokio::fs::remove_file(&script_path).await;
result
}
// ─── macOS ───────────────────────────────────────────────────────────────────
pub fn macos_options() -> InstallOptions {
let has_brew = which("brew");
InstallOptions {
os: "macos".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_brew,
auto_install_method: if has_brew { Some("brew".into()) } else { None },
auto_install_blocker: if has_brew {
None
} else {
Some("Homebrew not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .dmg from the official site.".into(),
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker socket is available, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
],
}
}
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
let brew = find_on_path("brew")
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Running: {} install --cask rancher", brew.display()),
);
let child = Command::new(&brew)
.args(["install", "--cask", "rancher"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch brew: {}", e))?;
stream(app, child).await
}
// ─── Windows ─────────────────────────────────────────────────────────────────
pub fn windows_options() -> InstallOptions {
let has_winget = which("winget");
InstallOptions {
os: "windows".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_winget,
auto_install_method: if has_winget { Some("winget".into()) } else { None },
auto_install_blocker: if has_winget {
None
} else {
Some("winget not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .msi from the official site.".into(),
"Run the installer and accept the WSL2 prompts if asked.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker engine is running, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
],
}
}
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
let _ = app.emit(
PROGRESS_EVENT,
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
);
let child = Command::new("winget")
.args([
"install",
"--id",
"SUSE.RancherDesktop",
"-e",
"--accept-package-agreements",
"--accept-source-agreements",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch winget: {}", e))?;
stream(app, child).await
}
// ─── Dispatcher ──────────────────────────────────────────────────────────────
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
if cfg!(target_os = "linux") {
run_linux_install(app).await
} else if cfg!(target_os = "macos") {
run_macos_install(app).await
} else if cfg!(target_os = "windows") {
run_windows_install(app).await
} else {
Err("auto-install is not supported on this OS".into())
}
}
+476 -36
View File
@@ -1,25 +1,187 @@
mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
mod install_helper;
mod logging;
mod models;
mod storage;
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 storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use storage::mcp_store::McpStore;
use tauri::Manager;
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager};
use tokio::sync::watch;
use web_terminal::WebTerminalServer;
pub struct AppState {
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub mcp_store: Arc<McpStore>,
pub exec_manager: Arc<ExecSessionManager>,
pub auth_bridge: Arc<AuthBridgeManager>,
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() {
@@ -39,19 +201,15 @@ pub fn run() {
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 auth_bridge = Arc::new(AuthBridgeManager::new());
let lifecycle = Arc::new(Lifecycle::new());
// Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone();
let settings_store_setup = settings_store.clone();
let exec_manager_setup = exec_manager.clone();
let lifecycle_setup = lifecycle.clone();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
@@ -60,9 +218,10 @@ pub fn run() {
.manage(AppState {
projects_store,
settings_store,
mcp_store,
exec_manager,
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
})
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
@@ -87,8 +246,9 @@ pub fn run() {
let set_store = settings_store_setup.clone();
let state = app.state::<AppState>();
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(
port,
token,
@@ -99,6 +259,16 @@ pub fn run() {
.await
{
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;
*guard = Some(server);
log::info!("Web terminal auto-started on port {}", port);
@@ -108,43 +278,130 @@ pub fn run() {
}
}
});
lifecycle_setup.track(handle);
}
}
// Auto-start STT container if enabled in settings
if settings.stt.enabled {
let stt_settings = settings.stt.clone();
tauri::async_runtime::spawn(async move {
match docker::stt::ensure_stt_running(&stt_settings).await {
Ok(status) => {
let cancel = lifecycle_setup.cancellation();
let handle = tauri::async_runtime::spawn(async move {
autostart_with_retry("STT container", cancel, || async {
let status = docker::stt::ensure_stt_running(&stt_settings).await?;
if status.running {
log::info!("STT container auto-started on port {}", stt_settings.port);
Ok(())
} else {
log::warn!("STT auto-start: container not running after ensure_stt_running");
}
}
Err(e) => {
log::error!("Failed to auto-start STT container: {}", e);
}
Err("container not running after ensure_stt_running".to_string())
}
})
.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(())
})
.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>();
tauri::async_runtime::block_on(async {
// Stop web terminal server
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
let lifecycle = state.lifecycle.clone();
// Already shutting down: let the window close. That covers our
// own `exit` unwinding it, and it deliberately leaves a second
// 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();
}
// Stop STT container
let _ = docker::stt::stop_stt_container().await;
// Close all exec sessions
state.exec_manager.close_all_sessions().await;
};
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()
);
}
app_handle.exit(0);
});
}
})
@@ -164,11 +421,43 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
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
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Terminal
@@ -177,18 +466,15 @@ pub fn run() {
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
// 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
commands::aws_commands::aws_sso_refresh,
// Updates
@@ -197,6 +483,9 @@ pub fn run() {
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
// Web Terminal
commands::web_terminal_commands::start_web_terminal,
commands::web_terminal_commands::stop_web_terminal,
@@ -209,7 +498,158 @@ pub fn run() {
commands::stt_commands::build_stt_image,
commands::stt_commands::pull_stt_image,
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!())
.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));
}
}
+60
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
@@ -32,6 +33,8 @@ pub struct GlobalAwsSettings {
pub aws_profile: Option<String>,
#[serde(default)]
pub aws_region: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
impl Default for GlobalAwsSettings {
@@ -40,14 +43,58 @@ impl Default for GlobalAwsSettings {
aws_config_path: None,
aws_profile: None,
aws_region: None,
default_model_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOllamaSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
/// Global fallback for the `haiku` alias override. Blank means "use the
/// resolved model id", which is what makes background Claude Code calls
/// work against a server that only serves one model.
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
/// Global defaults for the llama.cpp (`llama-server`) backend.
/// Mirrors [`GlobalOllamaSettings`]; used when the per-project field is blank.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalLlamaCppSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOpenAiCompatibleSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
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)]
pub default_git_user_name: Option<String>,
#[serde(default)]
@@ -60,6 +107,12 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_llamacpp: GlobalLlamaCppSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
@@ -79,6 +132,8 @@ pub struct AppSettings {
#[serde(default)]
pub stt: SttSettings,
#[serde(default)]
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
}
@@ -150,12 +205,16 @@ impl Default for AppSettings {
fn default() -> Self {
Self {
default_ssh_key_path: None,
ca_cert_path: None,
default_git_user_name: None,
default_git_user_email: None,
docker_socket_path: None,
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_llamacpp: GlobalLlamaCppSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
@@ -165,6 +224,7 @@ impl Default for AppSettings {
dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(),
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
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,
}
-70
View File
@@ -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)
}
}
+292
View File
@@ -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,
}
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod migration;
pub mod update_info;
pub mod mcp_server;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use migration::*;
pub use update_info::*;
pub use mcp_server::*;
+190 -10
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -28,6 +30,58 @@ fn default_full_permissions() -> bool {
true
}
/// `use_shared_auth_token` defaults to **on**: once the user has run
/// `claude setup-token` once, every existing Anthropic-backend project should
/// pick the token up without being edited one by one. Projects deliberately
/// pinned to their own `claude login` identity opt out.
fn default_use_shared_auth_token() -> bool {
true
}
/// 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.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
@@ -69,14 +123,56 @@ pub struct Project {
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>,
#[serde(default, alias = "llama_cpp_config")]
pub llamacpp_config: Option<LlamaCppConfig>,
#[serde(alias = "litellm_config")]
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool,
#[serde(default)]
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool,
/// Opt in to the auth bridge: while the container runs, its loopback
/// listeners are mirrored onto the host's loopback so browser OAuth
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
/// 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")]
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>,
/// Per-project override for the corporate CA certificate path (file or
/// directory). Blank falls back to `AppSettings::ca_cert_path`.
///
/// `#[serde(default)]` rather than a required field: every project stored
/// before this existed must keep loading.
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(skip_serializing, default)]
pub git_token: Option<String>,
pub git_user_name: Option<String>,
@@ -88,9 +184,10 @@ pub struct Project {
#[serde(default)]
pub claude_instructions: Option<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
#[serde(default)]
pub claude_code_settings: Option<ClaudeCodeSettings>,
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
pub created_at: String,
pub updated_at: String,
}
@@ -109,8 +206,10 @@ pub enum ProjectStatus {
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
@@ -118,6 +217,10 @@ pub enum Backend {
Anthropic,
Bedrock,
Ollama,
/// Serialises as `llama_cpp`; the aliases accept the spellings a
/// hand-edited `projects.json` is likely to contain.
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
LlamaCpp,
#[serde(alias = "lite_llm", alias = "litellm")]
OpenAiCompatible,
}
@@ -128,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.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
@@ -159,30 +284,67 @@ pub struct BedrockConfig {
pub aws_bearer_token: Option<String>,
pub model_id: Option<String>,
pub disable_prompt_caching: bool,
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
/// (e.g. "priority"). Empty/None means leave unset.
#[serde(default)]
pub service_tier: Option<String>,
}
/// Ollama configuration for a project.
/// Ollama exposes an Anthropic-compatible API endpoint.
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
pub base_url: String,
/// Optional model override (e.g., "qwen3.5:27b")
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// llama.cpp (`llama-server`) configuration for a project.
///
/// `llama-server` natively implements the Anthropic Messages API at
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
/// no translation shim.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlamaCppConfig {
/// The base URL of the llama-server instance. `llama-server`'s default
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
pub base_url: String,
/// Optional model override. `llama-server` serves whatever model it was
/// started with, so this is mostly the id Claude Code should *say* it is
/// using — but it is also what the model aliases are pinned to.
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// OpenAI Compatible endpoint configuration for a project.
/// 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)]
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,
/// API key for the OpenAI-compatible endpoint
/// API key for the endpoint
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
impl Project {
@@ -197,24 +359,42 @@ impl Project {
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
llamacpp_config: None,
openai_compatible_config: None,
allow_docker_access: false,
sandbox_mode_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,
permission_mode: None,
ssh_key_path: None,
ca_cert_path: None,
git_token: None,
git_user_name: None,
git_user_email: None,
custom_env_vars: Vec::new(),
port_mappings: Vec::new(),
claude_instructions: None,
enabled_mcp_servers: Vec::new(),
claude_code_settings: None,
renamed_session_names: HashMap::new(),
created_at: now.clone(),
updated_at: now,
}
}
/// The permission mode to actually use for this project.
/// Falls back to the legacy `full_permissions` boolean when the newer
/// `permission_mode` field has never been set.
pub fn effective_permission_mode(&self) -> PermissionMode {
self.permission_mode.unwrap_or(if self.full_permissions {
PermissionMode::Bypass
} else {
PermissionMode::Default
})
}
pub fn container_name(&self) -> String {
format!("triple-c-{}", self.id)
}
-106
View File
@@ -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 -3
View File
@@ -1,7 +1,7 @@
pub mod migration_store;
pub mod projects_store;
pub mod secure;
pub mod settings_store;
pub mod mcp_store;
#[allow(unused_imports)]
pub use projects_store::*;
@@ -9,5 +9,3 @@ pub use projects_store::*;
pub use secure::*;
#[allow(unused_imports)]
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> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
+226
View File
@@ -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.
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);
@@ -43,3 +71,201 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
}
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)
}
+172 -1
View File
@@ -159,7 +159,7 @@
position: absolute;
inset: 0;
display: none;
padding: 4px;
padding: 4px 4px 16px 4px;
}
.terminal-container.active { display: block; }
@@ -226,6 +226,51 @@
.scroll-bottom-btn:hover { background: var(--accent-hover); }
.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 {
display: flex;
@@ -272,6 +317,15 @@
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
</div>
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">&#8595;</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 &mdash; 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">&#10005;</button>
</div>
</div>
<!-- Input Bar for mobile/tablet -->
@@ -309,6 +363,114 @@
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
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 ──────────────────────────────
function connect() {
@@ -448,6 +610,15 @@
const webLinksAddon = new WebLinksAddon.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
const container = document.createElement('div');
container.className = 'terminal-container';
+10 -8
View File
@@ -205,11 +205,11 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
if project.full_permissions {
cmd.push("--dangerously-skip-permissions".to_string());
}
cmd.extend(permission_args);
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(),
);
let claude_cmd = if project.full_permissions {
"exec claude --dangerously-skip-permissions"
} else {
"exec claude"
};
// The args are interpolated into a shell script string below, so
// single-quote each one.
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}", permission_flags);
let script = format!(
r#"
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C",
"version": "0.3.0",
"version": "0.4.0",
"identifier": "com.triple-c.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -22,7 +22,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
"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": {
@@ -33,6 +33,7 @@
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.icns",
"icons/icon.png"
]
},
+195 -31
View File
@@ -1,26 +1,75 @@
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
import AddProjectDialog from "./components/projects/AddProjectDialog";
import ToastHost from "./components/ui/ToastHost";
import StatusIndicator from "./components/ui/StatusIndicator";
import Button from "./components/ui/Button";
import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useContainerProgress } from "./hooks/useContainerProgress";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, 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 [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
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
useContainerProgress();
useKeyboardShortcuts();
// Initialize on mount
useEffect(() => {
@@ -28,21 +77,13 @@ export default function App() {
let stopPolling: (() => void) | undefined;
checkDocker().then((available) => {
if (available) {
checkImage();
// 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();
});
onDockerReady();
} else {
stopPolling = startDockerPolling();
setShowInstallDialog(true);
stopPolling = startDockerPolling(onDockerReady);
}
});
refresh();
refreshMcp();
// Update detection
loadVersion();
@@ -58,16 +99,42 @@ export default function App() {
};
}, []); // 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 (
<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 />
<div className="flex flex-1 min-h-0 gap-4">
<div className="flex flex-1 min-h-0 gap-3">
<Sidebar />
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
{sessions.length === 0 ? (
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
{tabOrder.length === 0 ? (
<WelcomeScreen />
) : (
<div className="w-full h-full">
{homeProjectIds.map((projectId) => (
<ProjectHome
key={projectId}
projectId={projectId}
active={activeTabKey === homeTabKey(projectId)}
/>
))}
{sessions.map((session) => (
<TerminalView
key={session.id}
@@ -79,23 +146,120 @@ export default function App() {
)}
</main>
</div>
<StatusBar />
<StatusBar stt={stt} />
<ToastHost />
{showInstallDialog && (
<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>
);
}
/**
* First run is a checklist, not a paragraph: it reuses state the app already
* tracks and ends in a real button.
*/
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 (
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
<div className="text-center">
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
Triple-C
</h1>
<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.
<div className="flex items-center justify-center h-full p-6">
<div className="w-full max-w-md">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
Claude Code, sandboxed in a container.
</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>
);
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { useDocker } from "../hooks/useDocker";
import Modal from "./ui/Modal";
import Button from "./ui/Button";
interface Props {
onClose: () => void;
}
type Phase = "idle" | "installing" | "done" | "error";
export default function DockerInstallDialog({ onClose }: Props) {
const { options, loadOptions, runInstall } = useInstallHelper();
const { checkDocker } = useDocker();
const [showManual, setShowManual] = useState(false);
const [phase, setPhase] = useState<Phase>("idle");
const [log, setLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOptions();
}, [loadOptions]);
const handleInstall = async () => {
setPhase("installing");
setLog([]);
setError(null);
try {
await runInstall((line) => setLog((prev) => [...prev, line]));
setPhase("done");
// Re-check Docker so the rest of the app can proceed without a reload.
await checkDocker();
} catch (e) {
setError(String(e));
setPhase("error");
}
};
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
};
const handleRecheck = async () => {
const available = await checkDocker();
if (available) onClose();
};
if (!options) {
return null;
}
const installVerb =
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
return (
<Modal
title="Docker not detected"
onClose={onClose}
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
}
>
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
Triple-C needs a Docker-compatible runtime to manage sandboxed project
containers. We can install{" "}
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
you, or you can follow the official instructions.
</p>
{phase === "idle" && (
<div className="flex flex-col gap-2">
{options.can_auto_install ? (
<Button size="md" variant="primary" onClick={handleInstall}>
{installVerb} ({options.auto_install_method})
</Button>
) : (
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
One-click install unavailable:{" "}
<span className="text-[var(--text-primary)]">
{options.auto_install_blocker ?? "required tooling missing."}
</span>
</div>
)}
<Button size="md" onClick={() => setShowManual((s) => !s)}>
{showManual ? "Hide manual instructions" : "Show manual instructions"}
</Button>
<Button size="md" onClick={handleOpenDocs}>
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-[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>
))}
</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>
)}
{phase === "error" && (
<div className="flex flex-col gap-2">
<div className="text-[13px] text-[var(--error)]">Install failed.</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>
))}
</ol>
</div>
)}
{log.length > 0 && (
<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)]">
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
</Modal>
);
}
+13 -42
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { getHelpContent } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
interface Props {
onClose: () => void;
@@ -140,32 +142,16 @@ function renderMarkdown(md: string): string {
}
export default function HelpDialog({ onClose }: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [markdown, setMarkdown] = 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(() => {
getHelpContent()
.then(setMarkdown)
.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
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
@@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) {
}, []);
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-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
<button
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"
>
Close
</button>
</div>
{/* Scrollable content */}
<div
ref={contentRef}
onClick={handleContentClick}
className="flex-1 overflow-y-auto px-6 py-4 help-content"
<Modal
title="How to Use Triple-C"
onClose={onClose}
widthClassName="w-[48rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<div ref={contentRef} onClick={handleContentClick} className="help-content">
{error && (
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
<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>
<p className="text-[var(--text-secondary)] text-sm">Loading</p>
)}
{markdown && (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
)}
</div>
</div>
</div>
</Modal>
);
}
+267
View File
@@ -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");
}
});
});
+541
View File
@@ -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>
);
}
+9 -3
View File
@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
selector({
sidebarView: "projects",
setSidebarView: vi.fn(),
sidebarCollapsed: false,
setSidebarCollapsed: vi.fn(),
toggleSidebarCollapsed: vi.fn(),
})
),
}));
@@ -19,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({
vi.mock("../settings/SettingsPanel", () => ({
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
}));
vi.mock("../mcp/McpPanel", () => ({
default: () => <div data-testid="mcp-panel">McpPanel</div>,
}));
describe("Sidebar", () => {
beforeEach(() => {
@@ -34,6 +34,12 @@ describe("Sidebar", () => {
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", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
+88 -15
View File
@@ -1,15 +1,87 @@
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList";
import McpPanel from "../mcp/McpPanel";
import SettingsPanel from "../settings/SettingsPanel";
type SidebarView = "projects" | "settings";
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
{
view: "projects",
label: "Projects",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
),
},
{
view: "settings",
label: "Settings",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
useShallow(s => ({
sidebarView: s.sidebarView,
setSidebarView: s.setSidebarView,
sidebarCollapsed: s.sidebarCollapsed,
setSidebarCollapsed: s.setSidebarCollapsed,
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
}))
);
const tabCls = (view: typeof sidebarView) =>
if (sidebarCollapsed) {
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
const active = sidebarView === view;
return (
<button
key={view}
onClick={() => {
setSidebarView(view);
setSidebarCollapsed(false);
}}
title={label}
aria-label={label}
className={`flex items-center justify-center h-10 w-full transition-colors ${
active
? "text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{icon}
</button>
);
};
return (
<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
onClick={toggleSidebarCollapsed}
title="Expand sidebar"
aria-label="Expand sidebar"
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="flex flex-col py-1">
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
</div>
</div>
);
}
const tabCls = (view: SidebarView) =>
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === view
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
@@ -17,29 +89,30 @@ export default function Sidebar() {
}`;
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 */}
<div className="flex border-b border-[var(--border-color)]">
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
Projects
</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")}>
Settings
</button>
<button
onClick={toggleSidebarCollapsed}
title="Collapse sidebar"
aria-label="Collapse sidebar"
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
{sidebarView === "projects" ? (
<ProjectList />
) : sidebarView === "mcp" ? (
<McpPanel />
) : (
<SettingsPanel />
)}
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
</div>
</div>
);
+44 -5
View File
@@ -1,14 +1,31 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import SttButton from "../terminal/SttButton";
import type { useSTT } from "../../hooks/useSTT";
export default function StatusBar() {
const { projects, sessions, terminalHasSelection } = useAppState(
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
interface Props {
stt: ReturnType<typeof useSTT>;
}
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
} = useAppState(
useShallow(s => ({
projects: s.projects,
sessions: s.sessions,
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
}))
);
const running = projects.filter((p) => p.status === "running").length;
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>
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>
@@ -23,9 +40,31 @@ export default function StatusBar() {
{terminalHasSelection && (
<>
<span className="mx-2">|</span>
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span>
<span className="text-[var(--accent)]">
Ctrl+Shift+C: copy trimmed &middot; Ctrl+Shift+Alt+C: copy raw
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
<button
onClick={() => scrollActiveToBottom()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
>
Jump to Current
</button>
)}
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
error={stt.error}
onToggle={stt.toggle}
onCancel={stt.cancelRecording}
/>
)}
</div>
</div>
);
}
+50 -21
View File
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import TerminalTabs from "../terminal/TerminalTabs";
import MainTabs from "./MainTabs";
import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
import HelpDialog from "./HelpDialog";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
export default function TopBar() {
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
@@ -48,34 +49,48 @@ export default function TopBar() {
return (
<>
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex-1 overflow-x-auto pl-2">
<TerminalTabs />
<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-1">
<MainTabs />
</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 && (
<button
type="button"
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
</button>
)}
{imageUpdateInfo && (
<button
type="button"
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"
>
Image Update
</button>
)}
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
<HealthDot
state={dockerAvailable}
okLabel="Docker"
failLabel="Docker unavailable"
pendingLabel="Docker — checking"
/>
<HealthDot
state={imageExists}
okLabel="Image"
failLabel="Image missing"
pendingLabel="Image — checking"
/>
<button
type="button"
onClick={() => setShowHelpDialog(true)}
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>
@@ -103,15 +118,29 @@ export default function TopBar() {
);
}
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
return (
<span className="flex items-center gap-1">
<span
className={`inline-block w-2 h-2 rounded-full ${
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
}`}
/>
{label}
</span>
);
/**
* `null` (still checking) is visually distinct and pulses; `false` is an
* outage and renders red previously both fell through to the same gray dot.
*/
function HealthDot({
state,
okLabel,
failLabel,
pendingLabel,
}: {
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} />;
}
-79
View File
@@ -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>
);
}
-331
View File
@@ -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://&lt;container&gt;:&lt;port&gt;/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 { useProjects } from "../../hooks/useProjects";
import type { ProjectPath } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { inputClass, monoInputClass } from "../ui/Field";
interface Props {
onClose: () => void;
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
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 formId = useId();
const handleBrowse = async (index: number) => {
const selected = await open({ directory: true, multiple: false });
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
}
};
const updateEntry = (
index: number,
field: keyof PathEntry,
value: string,
) => {
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
const entries = [...pathEntries];
entries[index] = { ...entries[index], [field]: value };
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) => {
if (e) e.preventDefault();
if (!name.trim()) {
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
<Modal
title="Add Project"
onClose={onClose}
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">
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
<form onSubmit={handleSubmit}>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Name
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={`${formId}-name`}
className="block text-[13px] font-medium mb-1"
>
Project name
</label>
<input
id={`${formId}-name`}
ref={nameInputRef}
value={name}
onChange={(e) => setName(e.target.value)}
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">
Folders
</label>
<div className="space-y-2 mb-3">
<div>
<span className="block text-[13px] font-medium mb-1">Folders</span>
<div className="space-y-2">
{pathEntries.map((entry, i) => (
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
<div className="flex gap-1">
<div
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
value={entry.host_path}
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
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
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"
>
<Button size="md" onClick={() => handleBrowse(i)}>
Browse
</button>
</Button>
{pathEntries.length > 1 && (
<button
type="button"
onClick={() => removeEntry(i)}
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
<Button
size="md"
variant="danger"
aria-label={`Remove folder ${i + 1}`}
onClick={() =>
setPathEntries(pathEntries.filter((_, j) => j !== i))
}
>
x
</button>
Remove
</Button>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
/workspace/
</span>
<input
value={entry.mount_name}
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
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>
<button
type="button"
onClick={addEntry}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
<Button
className="mt-2"
onClick={() =>
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
}
>
+ Add folder
</button>
</Button>
</div>
{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)]"
>
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
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>
</form>
</div>
</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 Modal from "../ui/Modal";
import Button from "../ui/Button";
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
interface Props {
settings: ClaudeCodeSettings | null;
@@ -8,184 +10,32 @@ interface Props {
onClose: () => void;
}
const 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 {
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
export default function ClaudeCodeSettingsModal({
settings,
disabled,
onSave,
onClose,
}: Props) {
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
);
}
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);
<Modal
title="Claude Code Settings"
onClose={onClose}
widthClassName="w-[34rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<ClaudeCodeSettingsEditor
settings={settings}
disabled={disabled}
disabledReason="Container must be stopped to change Claude Code settings."
onSave={async (next) => {
try {
await onSave(isAllDefaults(next) ? null : next);
await onSave(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">
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</h2>
{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 Claude Code settings.
</div>
)}
<div className="space-y-4 mb-6">
{/* TUI Mode */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<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>
}}
/>
</Modal>
);
}
@@ -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 {
instructions: string;
@@ -7,74 +9,35 @@ interface Props {
onClose: () => void;
}
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
const [instructions, setInstructions] = useState(initial);
const overlayRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.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 handleBlur = async () => {
try { await onSave(instructions); } catch (err) {
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
export default function ClaudeInstructionsModal({
instructions,
disabled,
onSave,
onClose,
}: Props) {
return (
<Modal
title="Claude Instructions"
description="Written to ~/.claude/CLAUDE.md inside containers."
onClose={onClose}
widthClassName="w-[40rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<ClaudeInstructionsEditor
instructions={instructions}
disabled={disabled}
disabledReason="Container must be stopped to change Claude instructions."
rows={14}
autoFocus
onSave={async (value) => {
try {
await onSave(value);
} catch (err) {
console.error("Failed to update Claude instructions:", 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-[40rem] shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
</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 Claude instructions.
</div>
)}
<textarea
ref={textareaRef}
value={instructions}
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>
</Modal>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
interface Props {
projectName: string;
@@ -7,49 +8,31 @@ interface 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 (
<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-[24rem] shadow-xl">
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
<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"
>
<Modal
title="Remove Project"
onClose={onCancel}
widthClassName="w-[26rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button
</Button>
<Button
size="md"
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
</button>
</div>
</div>
</div>
</Button>
</>
}
>
<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>&rsquo;s
container from the clean base image.
</p>
<p>
This deletes the container&rsquo;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> &mdash; 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]} &ldquo;{projectName}&rdquo;
</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,103 @@
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>
)}
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
carries `w-full`, and a width utility on the input itself does not beat
it class-attribute order is not what resolves the conflict, stylesheet
order is. Sizing the key input directly left it asking for the whole row
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
leftover space, to an unusable sliver. */}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<div className="w-2/5 shrink-0">
<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={monoInputClass}
/>
</div>
<div className="flex-1 min-w-0">
<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={monoInputClass}
/>
</div>
<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>
);
}
+22 -110
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { EnvVar } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import EnvVarsEditor from "./EnvVarsEditor";
interface Props {
envVars: EnvVar[];
@@ -8,117 +10,27 @@ interface Props {
onClose: () => void;
}
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
const [vars, setVars] = useState<EnvVar[]>(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 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) {
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
return (
<Modal
title="Environment Variables"
onClose={onClose}
widthClassName="w-[36rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<EnvVarsEditor
envVars={envVars}
disabled={disabled}
disabledReason="Container must be stopped to change environment variables."
onSave={async (vars) => {
try {
await onSave(vars);
} catch (err) {
console.error("Failed to update environment variables:", 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-4">Environment Variables</h2>
{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 environment variables.
</div>
)}
<div className="space-y-2 mb-4">
{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={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>
</Modal>
);
}
@@ -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)]">
<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)]">
<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.812.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>
);
}

Some files were not shown because too many files have changed in this diff Show More