Compare commits

..
Author SHA1 Message Date
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
54 changed files with 8188 additions and 362 deletions
+289 -28
View File
@@ -1,10 +1,67 @@
name: Build App (Preview) name: Build App (Preview)
# Builds the Tauri app for branches other than main and exposes the bundles as # Builds the Tauri app for branches other than main and publishes the bundles as
# workflow artifacts. No Gitea release, no GitHub sync — intended for local # a **prerelease**, so they are downloadable from the Releases page. No GitHub
# smoke-testing of feature branches before they merge. # sync.
#
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
# a push that breaks any of them fails here. build-app.yml used to do that job
# in parallel and publish nothing, which meant six OS builds per push and one
# unreachable set of bundles; it is now releases-only.
#
# The cost of the swap, stated plainly: one prerelease per PR commit that
# touches `app/**` — so the workflow prunes its own, keeping the newest
# KEEP_PREVIEWS (see Lifecycle).
#
# ## Why not workflow artifacts
#
# Two attempts failed before this one, and both failure modes are worth knowing:
#
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
# throws before making a single request. act_runner sets that variable to this
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
# the whole Tauri build had been paid for (run #265).
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
# `total_count: 0` and the run page shows nothing (verified on run #267).
# A build nobody can find is not a build.
#
# So previews publish the same way every other workflow here does: curl to the
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
#
# ## Lifecycle
#
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
# release list, and a manual cleanup sweeps any this workflow missed.
#
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
# pushed commit accumulates faster than anyone runs it. So the last job here
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
# release; the point of a preview is the build you are testing now.
#
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
env:
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
# How many preview releases survive a run, newest first — including the one
# just published.
KEEP_PREVIEWS: "2"
on: on:
# Every push to an open PR: this *is* the branch's build check — it compiles
# Linux, macOS and Windows — and publishing the result costs nothing extra
# once they are built. build-app.yml deliberately no longer runs on PRs.
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app-preview.yml"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -12,6 +69,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.version.outputs.VERSION }} version: ${{ steps.version.outputs.VERSION }}
sha: ${{ steps.version.outputs.SHA }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -23,13 +81,88 @@ jobs:
run: | run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]') MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
SHORT_SHA=$(git rev-parse --short HEAD) SHORT_SHA=$(git rev-parse --short HEAD)
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}" # From the checkout, not from `gitea.sha`: on a pull_request event
# that variable can be the merge ref, which is not the commit anyone
# is testing and not something to hang a tag on.
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# The patch number is computed exactly as build-app.yml does it, so a
# preview is labelled with the version the release it previews would
# carry. This used to be hard-coded `.0`, which made every preview
# installer claim to be x.y.0 no matter what it contained.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
if [ -n "$LATEST_TAG" ]; then
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
else
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
PATCH=0
fi
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed preview version: ${VERSION}" echo "Computed preview version: ${VERSION}"
build-linux: # One release, created once. The three build jobs run concurrently, so
# get-or-create in each of them would race on the same tag: whoever loses gets
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
# still reported success. Creating it in a job they all depend on removes the
# race rather than handling it.
create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [compute-version] needs: [compute-version]
outputs:
release_id: ${{ steps.release.outputs.RELEASE_ID }}
tag: ${{ steps.release.outputs.TAG }}
steps:
- name: Create the preview release
id: release
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
SHA: ${{ needs.compute-version.outputs.sha }}
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
run: |
set -euo pipefail
TAG="preview-${VERSION##*.}"
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
# Idempotent: re-dispatching the same commit must update the existing
# release rather than fail on the duplicate tag.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200) echo "Release ${TAG} already exists, reusing" ;;
404)
echo "Creating release ${TAG}"
# prerelease: true keeps it off "latest" — this is a branch build,
# not something anyone should install by accident.
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "Release ${TAG} is id ${RELEASE_ID}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version, create-release]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -128,17 +261,47 @@ jobs:
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
- name: Upload Linux artifacts # Assets, not workflow artifacts — see the note at the top of this file.
uses: actions/upload-artifact@v4 # Delete-then-upload so a re-dispatch replaces rather than 409s, and the
with: # retry/http1.1 hardening that build-app.yml learned from real macOS
name: triple-c-${{ needs.compute-version.outputs.version }}-linux # upload failures (curl exit 92 and exit 28 mid-stream).
path: artifacts/ - name: Upload Linux bundles to the preview release
if-no-files-found: error shell: bash
retention-days: 14 env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
set -euo pipefail
shopt -s nullglob
files=(artifacts/*)
if [ ${#files[@]} -eq 0 ]; then
echo "No Linux bundles were produced" >&2
exit 1
fi
for file in "${files[@]}"; do
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Replacing existing asset ${filename}"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version] needs: [compute-version, create-release]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -209,17 +372,47 @@ jobs:
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
- name: Upload macOS artifacts # Assets, not workflow artifacts — see the note at the top of this file.
uses: actions/upload-artifact@v4 # Delete-then-upload so a re-dispatch replaces rather than 409s, and the
with: # retry/http1.1 hardening that build-app.yml learned from real macOS
name: triple-c-${{ needs.compute-version.outputs.version }}-macos # upload failures (curl exit 92 and exit 28 mid-stream).
path: artifacts/ - name: Upload macOS bundles to the preview release
if-no-files-found: error shell: bash
retention-days: 14 env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
set -euo pipefail
shopt -s nullglob
files=(artifacts/*)
if [ ${#files[@]} -eq 0 ]; then
echo "No macOS bundles were produced" >&2
exit 1
fi
for file in "${files[@]}"; do
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Replacing existing asset ${filename}"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-windows: build-windows:
runs-on: windows-latest runs-on: windows-latest
needs: [compute-version] needs: [compute-version, create-release]
defaults: defaults:
run: run:
shell: cmd shell: cmd
@@ -308,10 +501,78 @@ jobs:
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
dir artifacts\ dir artifacts\
- name: Upload Windows artifacts # PowerShell, because this job's default shell is cmd. Same
uses: actions/upload-artifact@v4 # delete-then-upload shape as the other two.
with: - name: Upload Windows bundles to the preview release
name: triple-c-${{ needs.compute-version.outputs.version }}-windows shell: powershell
path: artifacts/ env:
if-no-files-found: error TOKEN: ${{ secrets.REGISTRY_TOKEN }}
retention-days: 14 RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
$files = @(Get-ChildItem -File -Path artifacts\*)
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
foreach ($file in $files) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Replacing existing asset $name"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
# Keep the preview list short. Runs after the builds and only if all three
# succeeded: a half-published run must not be what evicts a good older build.
prune-previews:
runs-on: ubuntu-latest
needs: [create-release, build-linux, build-macos, build-windows]
steps:
- name: Delete all but the newest preview releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
run: |
set -euo pipefail
curl -fsS -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
# Newest first by creation time, `preview-` only, and never the one
# this run just published — a clock skew must not delete it.
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
import json, sys
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
previews = [r for r in json.load(open("releases.json"))
if r["tag_name"].startswith("preview-")]
previews.sort(key=lambda r: r["created_at"], reverse=True)
for r in previews[keep:]:
if r["tag_name"] != keep_tag:
print(r["id"], r["tag_name"])
PY
)
if [ -z "${DOOMED}" ]; then
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
exit 0
fi
echo "${DOOMED}" | while read -r ID TAG; do
[ -z "${ID}" ] && continue
echo "Deleting ${TAG} (id ${ID})"
# Best effort: a preview someone deleted by hand mid-run is not a
# reason to fail a build that otherwise succeeded.
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
done
+45 -8
View File
@@ -7,14 +7,14 @@ on:
- "app/**" - "app/**"
- "VERSION" - "VERSION"
- ".gitea/workflows/build-app.yml" - ".gitea/workflows/build-app.yml"
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
workflow_dispatch: workflow_dispatch:
# Deliberately **not** on pull_request. Every publishing step here is gated on
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
# the same three and publishes them. Six OS builds per push, one set of which
# was unreachable. Previews now carry the PR check; this workflow is releases.
env: env:
GITEA_URL: ${{ gitea.server_url }} GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }} REPO: ${{ gitea.repository }}
@@ -47,8 +47,12 @@ jobs:
echo "Latest matching tag: ${LATEST_TAG}" echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count" # A minor line nobody has tagged yet is a *new* line, and a new line
PATCH=$(git rev-list --count HEAD) # starts at .0 — that is what "we are moving to 0.4.x" means. The
# old fallback here counted every commit in the repository, which
# would have made the first 0.4 build 0.4.234.
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
PATCH=0
fi fi
VERSION="${MAJOR_MINOR}.${PATCH}" VERSION="${MAJOR_MINOR}.${PATCH}"
@@ -395,6 +399,39 @@ jobs:
) )
endlocal endlocal
- name: Work around WOW64 redirection for 32-bit bundlers
shell: cmd
run: |
rem Tauri downloads its bundlers - candle.exe, light.exe and
rem makensis.exe - and every one of them is 32-bit. When the runner
rem runs as SYSTEM its %LOCALAPPDATA% is under
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
rem serves any 32-bit process reading System32 from SysWOW64 instead -
rem where those directories do not exist. The bundlers then cannot see
rem their own folder: candle exits 0x80131700 and makensis reports
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
rem only "failed to run candle.exe", which is why this is worth a
rem comment this long.
rem
rem Junctioning the SysWOW64 view onto the System32 originals makes the
rem redirected path resolve to the same files. A runner running as a
rem normal user has a profile outside System32 and skips all of this.
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
if errorlevel 1 goto skipwow
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
echo WOW64 junctions in place for the SYSTEM profile
goto :eof
:skipwow
echo Runner profile is outside System32 - WOW64 junctions not needed
- name: Install Rust stable - name: Install Rust stable
run: | run: |
where rustup >nul 2>&1 && ( where rustup >nul 2>&1 && (
+121 -3
View File
@@ -59,6 +59,17 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The - **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current. `home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
`moveTab` deliberately does not activate what it moves.
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
default handler types into any text field the drag is released over.
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
excluding xterm's helper textarea, which is an input-method shim rather than a field.
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`) - **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models - **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow - **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
@@ -84,8 +95,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Use `--text-disabled` rather than `disabled:opacity-50`. Use `--text-disabled` rather than `disabled:opacity-50`.
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`. - **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word. - **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump. - Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal. `Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
why tab-moving takes Shift.
### Backend Structure (`app/src-tauri/src/`) ### Backend Structure (`app/src-tauri/src/`)
@@ -104,6 +117,61 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
OAuth listener, wrong for remote control of a browser. Host ports are confined to 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; `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. a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
can be watched on another monitor or pinned on top while the main window is used for work.
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
not give it one; the app CSP does not apply, because it is a top-level document rather than a
frame, and the token gate is what protects the port in both cases; and the window is owned by
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
the browser.
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
container-side browser closes the loop with no host round trip and no auth bridge) and dev
servers on container loopback. **Verified: a second client cannot join a bound browser**
`chromium.connect()` against the published endpoint times out in every URL form, because that
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
is the only process that can drive, which is why the helper is resident and why live resize
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
no second listener — and a re-open with a helper already up *navigates* rather than
relaunching, so a session signed in on one page survives to the next.
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
settled `Resized` size into it — debounced by generation counter, since a drag emits
continuously and each one costs a container exec.
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
container stopped, process exited.
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does
not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the
wrapper alone reports a current build as "predates `browser.bind()`".
- **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that binds,
but never `@playwright/cli`, which is the viewer. Never offer it as a setup route — only as
what binds sessions automatically once Playwright is present.
- **`install.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is
*not* a bind mount — project directories are mounted at `/workspace/{mount_name}` — so this
touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned),
and is on the module resolution path for scripts in the project. Browsers go to
`~/.cache/ms-playwright` as `claude`, i.e. the home volume.
- **Current base images ship Chromium's shared libraries; older ones do not** — and a project
keeps the base image it was first built from until it is migrated, so "older" is the normal
case. Without them `playwright install chromium` downloads a browser that cannot launch, which
is why installing Chrome via apt looks like a fix. `install.rs` asks
`install-deps --dry-run` first and skips the apt step when the answer is "all present",
*saying so* in the progress stream. Do not decide this by probing for library names: the
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
everything is installed and when Playwright has no list for the platform — match on its
output, not its exit code. Either way the action ends by *actually launching* the browser to
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
offered.
- **`docker/`** — Docker API layer using bollard: - **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock` - `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
@@ -134,7 +202,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Container (`container/`) ### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
libraries a browser links against (see below)
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do
not replace it with a hand-written apt list without pinning the Playwright version you derived
it from — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
fonts) and nothing more, because `libgbm1``mesa-libgallium``libllvm20` is ~213 MiB that
no trimming removes.
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
platform Playwright's table does not cover, `install-deps` prints a warning and returns having
installed nothing **with exit status 0**. Without the assertion that ships a broken image
behind a clean build log.
- Baking the libraries but not the browsers is the whole point of the split. Browsers live in
`~/.cache/ms-playwright` (home volume) and already survive recreation *and* migration; a
runtime `apt-get install` of the libraries lands in the writable layer, is re-paid after every
Reset, and is **lost on base-image migration**, which replays apt from a manifest. The runtime
approach converges on the worst state: a 400 MB browser present with its libraries gone.
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
@@ -155,6 +243,36 @@ ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks an
re-attach for free when a container is recreated from a *different* image — which is what makes re-attach for free when a container is recreated from a *different* image — which is what makes
base-image migration cheap. base-image migration cheap.
### Corporate CA certificates (`docker/ca_certs.rs`, `entrypoint.sh`)
A global `AppSettings::ca_cert_path` with a per-project `Project::ca_cert_path` override, accepting
a single certificate file **or** a directory. Follows the SSH/AWS host-mount pattern: read-only
bind mount at `/tmp/.host-ca`, applied by the entrypoint on every start, so it survives recreation,
migration and Reset. Four things here are not obvious:
- **`update-ca-certificates` globs `*.crt`, case-sensitively.** A `.pem` that is merely copied into
`/usr/local/share/ca-certificates/` is ignored in total silence. Certificates are *renamed*
`container_cert_name()` in Rust, mirrored in a few lines of shell in `entrypoint.sh` (the Rust
side carries the unit tests). A single-file mount lands at `/tmp/.host-ca/<name>.crt` so the
entrypoint only ever sees a directory and the file keeps a recognisable name.
- **The system store is not enough.** Only curl/git/apt read it. Node — and therefore Claude Code
itself — needs `NODE_EXTRA_CA_CERTS`; Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`;
Chrome/Chromium read neither and want their own NSS database at `~/.pki/nssdb`, seeded with
`certutil` (`libnss3-tools`, added to the image for this). The NSS step warns and continues if
`certutil` is missing rather than failing the start.
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
session is a `docker exec`, which inherits the container's configured env and sees nothing the
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. The bundle path
is deterministic (`/etc/ssl/certs/ca-certificates.crt`), so Rust can set them up front. They are
emitted **empty** when no CA is configured, for the `MANAGED_AUTH_KEYS` reason: `docker commit`
bakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl, `openssl
s_client` and Python's `ssl` behave exactly as with the vars unset.
- **`triple-c.ca-fingerprint` covers the certificate *bytes*, not just the path.** Replacing a
rotated CA at the same location must recreate the container; the copy inside is made once, at
start, so nothing else would notice. The entrypoint is stamped/idempotent on restart, and
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
project's snapshot image, so turning the feature off has to undo, not merely stop.
### Container Lifecycle ### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
+106 -3
View File
@@ -191,6 +191,12 @@ Anthropic-backend project uses that token without its own login. See
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline. terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
a terminal. a terminal.
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
Dropping does not change which tab you are looking at — so you can rearrange the strip without
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
*active* tab the same way without the mouse (they leave text fields alone, where that chord
still selects by word). The order is per-session: it is not saved when you quit.
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs - **Status indicators (top right)** — Docker connection and container image availability. Each pairs
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
the built-in help. the built-in help.
@@ -211,7 +217,7 @@ for selecting a project and for two quick controls that appear on hover — star
Claude terminal. Everything else about a project lives in Project Home. Claude terminal. Everything else about a project lives in Project Home.
The header shows the project name, its status, how long the container has been up, and the action The header shows the project name, its status, how long the container has been up, and the action
buttons. Below that are five tabs: buttons. Below that are six tabs:
| Tab | What it's for | | Tab | What it's for |
|---|---| |---|---|
@@ -220,6 +226,7 @@ buttons. Below that are five tabs:
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) | | **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) | | **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
| **Files** | Browse, download and upload files inside the container | | **Files** | Browse, download and upload files inside the container |
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
### Sessions ### Sessions
@@ -257,6 +264,61 @@ included, and each tile opens a list of what it found.
The counts are only available while the container is running. The counts are only available while the container is running.
### The Browser Tab
When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you
that browser live — and lets you take it over with your own mouse and keyboard.
It is **off by default and opted into per project**, and it never installs anything on its own.
Opening the tab only *probes* the container, so it can tell you what is missing before you ask for
a view; installing Playwright and downloading a browser are separate, labelled buttons that state
what they cost before you press them. See
[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not
pre-installed.
Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the
container and reached over a token-gated listener on your machine's loopback address. Nothing is
exposed off the machine.
#### Opening a page yourself
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
publishes it to this pane. Two uses:
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
a container-side browser completes the login without anything crossing to your host browser.
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
offers **In container**, which does the same thing in one click.
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
around it yourself.
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
be *watched* but not resized — a published browser admits only the client that launched it. Set
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
instead.
#### Watching it while you work
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
for the reason above. This is a window change only: the browser and the view keep running throughout, so
popping out and back costs nothing and interrupts nothing.
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
two viewers would both be able to *drive* the browser, and two cursors on one page is not useful.
**Put back in tab**, or just closing the window, brings it back.
The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and
stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes
it, because a window showing a viewer that no longer exists is worse than no window.
--- ---
## Project Management ## Project Management
@@ -509,6 +571,10 @@ This lives in the sidebar under **Settings → Claude Authentication**.
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback. code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain. 4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
The code is long and easy to truncate. If Anthropic refuses it, the dialog says so and lets you
paste another one without restarting the sign-in — the CLI is still waiting. After a few refusals
the flow gives up and reports it rather than sitting there.
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
token requires a Claude subscription; without one, `setup-token` finishes without printing a token token requires a Claude subscription; without one, `setup-token` finishes without printing a token
and nothing is stored. and nothing is stored.
@@ -824,8 +890,8 @@ Notes:
## Settings ## Settings
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Git / SSH**, sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Certificates**,
**Tools** and **Updates**. **Git / SSH**, **Tools** and **Updates**.
### Claude Authentication ### Claude Authentication
@@ -855,6 +921,36 @@ Environment variables applied to **all** project containers. Per-project variabl
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence. Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
### Corporate CA Certificate
If your organisation's network inspects TLS (a corporate proxy, a VPN that terminates HTTPS at the
edge), containers need your organisation's root certificate or **every** HTTPS call inside them
fails — `npm install`, `pip`, `git clone` over HTTPS, `curl`, the browser-view pane, and Claude
Code's own calls to the API.
Point this at either a **single certificate file** or a **folder** of them. It is mounted read-only
into every container and applied on every start, so it survives container recreation, base-image
migration and Reset — unlike a certificate you install by hand inside a running container, which is
lost the first time any of those happens.
The status line under the field tells you how many certificates were found and the names they will
be installed as inside the container. That rename matters: the container's trust store only reads
files ending in `.crt`, so a `.pem` is renamed rather than merely copied, which is the step that is
easiest to get wrong by hand.
Inside the container the certificate is trusted by:
| Consumer | How |
|---|---|
| curl, git, apt, wget | the system trust store (`update-ca-certificates`) |
| Node, npm, **Claude Code itself** | `NODE_EXTRA_CA_CERTS` |
| Python, pip, requests | `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` |
| Chrome / Chromium (browser view) | its own NSS database at `~/.pki/nssdb` |
A per-project override lives in **Project Home → Config → Access**; leave it blank to use this
global setting. Changing either recreates the project's container on its next start — replacing the
certificate file in place counts as a change, so a rotated CA is picked up too.
### Default Git Name / Email ### Default Git Name / Email
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project. Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
@@ -1085,6 +1181,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
| **Ctrl+Tab** | Switch to the next tab | | **Ctrl+Tab** | Switch to the next tab |
| **Ctrl+Shift+Tab** | Switch to the previous tab | | **Ctrl+Shift+Tab** | Switch to the previous tab |
| **Ctrl+1****Ctrl+9** | Jump to the first through ninth tab | | **Ctrl+1****Ctrl+9** | Jump to the first through ninth tab |
| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) |
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word > **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it > before the cursor, and it is used constantly in the terminal this app is built around. Binding it
@@ -1127,6 +1224,12 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support. The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
It also ships the **system libraries a browser needs to run** (`libnss3`, `libgbm1`, `libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, fonts, and the rest of the set Playwright asks for). So `npx playwright install chromium` gives you a browser that actually starts. Before these were baked in, that download succeeded and the browser then died with *"Host system is missing dependencies: libnss3.so"*, which is why `sudo apt install google-chrome-stable` looked like the cure — apt was quietly installing the same libraries as Chrome's own dependencies.
The **browsers themselves are not pre-installed** — they are hundreds of megabytes and tied to the Playwright version you use. Install one with the Browser tab's setup buttons, or `npx playwright install chromium` in a terminal. They land in `~/.cache/ms-playwright`, which is on the home volume, so a browser survives container recreation and base-image migration and is only lost on a project **Reset**.
If your project's container was created from an older base image, it won't have the libraries — the Browser tab's install action detects that and installs them for you first, and says so while it does. That install lives in the container's writable layer, so it is undone by a **Reset** and by a base-image migration; migrating the project onto the current base image is what picks the libraries up for good.
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets). You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
--- ---
+20
View File
@@ -386,4 +386,24 @@ Users can override this in Settings via the global `docker_socket_path` option.
**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) **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.
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host) **Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
+23 -1
View File
@@ -245,9 +245,31 @@ minutes.
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the - **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
frontend, never written to a log, and no command accepts or returns it. frontend, never written to a log, and no command accepts or returns it.
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
parameter is container output that is never rendered, which makes it the *easier* place to hide a
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
15-minute timeout with the UI still saying "Finishing sign-in".
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful - **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
could still grow into a secret across a chunk boundary. could still grow into a secret across a chunk boundary. A credential split across a hard line
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
token onto the next frame's first word.
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project - **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
+1 -1
View File
@@ -1 +1 @@
0.3 0.4
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "triple-c", "name": "triple-c",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "triple-c", "name": "triple-c",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "triple-c", "name": "triple-c",
"private": true, "private": true,
"version": "0.3.0", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -5163,7 +5163,7 @@ dependencies = [
[[package]] [[package]]
name = "triple-c" name = "triple-c"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "triple-c" name = "triple-c"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
[lib] [lib]
+291 -23
View File
@@ -4,7 +4,8 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use crate::browser_view::{manager, BrowserViewStatus}; use crate::browser_view::install::{self, BrowserSetupOutcome};
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
use crate::AppState; use crate::AppState;
/// Turn the pane on or off for a project. /// Turn the pane on or off for a project.
@@ -27,20 +28,7 @@ pub async fn set_browser_view_enabled(
return Ok(manager().status(&project_id).await); return Ok(manager().status(&project_id).await);
} }
let project = state let container_id = running_container(&state, &project_id, "opening the browser view").await?;
.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("Start the container before opening the browser view.".to_string());
};
if !crate::docker::container::is_container_running(&container_id)
.await
.unwrap_or(false)
{
return Err("Start the container before opening the browser view.".to_string());
}
manager() manager()
.start( .start(
@@ -61,18 +49,298 @@ pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewSt
/// Probe the container for Playwright without starting anything. /// Probe the container for Playwright without starting anything.
/// ///
/// Lets the pane say "install this" before the user asks for a view, and lets /// Lets the pane say "install this" before the user asks for a view, and lets
/// them re-check after installing without toggling the feature. /// them re-check after installing without toggling the feature. Read-only: it
/// runs one `node -e` and changes nothing.
#[tauri::command] #[tauri::command]
pub async fn check_browser_view_support( pub async fn check_browser_view_support(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> { ) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
let project = state let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.ok_or_else(|| "Start the container to check for Playwright.".to_string())?;
crate::browser_view::detect::detect(&container_id).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)
}
+467 -32
View File
@@ -17,6 +17,22 @@
//! Discovery of published browsers is local-filesystem based (a cache directory //! 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 //! 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. //! 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 serde::{Deserialize, Serialize};
@@ -39,6 +55,14 @@ pub struct PlaywrightDetection {
/// Absolute path of the resolved package manifest, for the diagnostics line. /// Absolute path of the resolved package manifest, for the diagnostics line.
#[serde(default)] #[serde(default)]
pub playwright_path: Option<String>, 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()`. /// Whether the resolved build's type definitions declare `Browser.bind()`.
#[serde(default)] #[serde(default)]
pub has_bind: bool, pub has_bind: bool,
@@ -50,6 +74,51 @@ pub struct PlaywrightDetection {
/// we can signal. /// we can signal.
#[serde(default)] #[serde(default)]
pub cli_entry: Option<String>, 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. /// Where the probe looked, echoed back for the "not found" message.
#[serde(default)] #[serde(default)]
pub searched: Vec<String>, pub searched: Vec<String>,
@@ -63,6 +132,13 @@ impl PlaywrightDetection {
/// A specific, actionable explanation of what is missing. `None` when the /// A specific, actionable explanation of what is missing. `None` when the
/// container is ready. /// 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> { pub fn blocker(&self) -> Option<String> {
if self.node_version.is_none() { if self.node_version.is_none() {
return Some( return Some(
@@ -72,41 +148,135 @@ impl PlaywrightDetection {
} }
if self.playwright_version.is_none() { if self.playwright_version.is_none() {
return Some(format!( return Some(format!(
"Playwright isn't installed in this container. Install it with \ "Playwright isn't installed in this container. Two packages are needed: \
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \ `playwright` (for the `browser.bind()` live-dashboard API) and \
`await browser.bind('claude')` after launching a browser or use \ `@playwright/cli` (the viewer UI this pane embeds). Use Set up Playwright \
`@playwright/mcp`, which binds automatically. Looked in: {}.", below to install both into the container. Installing `@playwright/mcp` on \
if self.searched.is_empty() { its own is not enough it binds sessions for you once Playwright is there, \
"the container's default module paths".to_string() but it never provides the viewer. Looked in: {}.",
} else { self.searched_text()
self.searched.join(", ")
}
)); ));
} }
if !self.has_bind { if !self.has_bind {
return Some(format!( return Some(format!(
"Playwright {} is installed, but it predates the live-dashboard API \ "Playwright {} is installed{}, but it predates the live-dashboard API \
(`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \ (`browser.bind()`). Use Set up Playwright below to upgrade to the latest \
the browser Claude is driving.", `playwright`, then restart the browser Claude is driving.",
self.playwright_version.as_deref().unwrap_or("?") 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() { if self.cli_entry.is_none() {
return Some( return Some(format!(
"Playwright is installed, but the viewer UI package isn't. Install it with \ "Playwright {} is installed, but `@playwright/cli` — the package that serves \
`npm i -D @playwright/cli`, then reopen this tab." the viewer UI isn't, and nothing else provides it (`@playwright/mcp` does \
.to_string(), not). Use Set up Playwright below to install it. Looked in: {}.",
); self.playwright_version.as_deref().unwrap_or("?"),
self.searched_text()
));
} }
None 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. /// One `node -e` probe, run as `claude` inside the container.
/// ///
/// No shell quoting is involved: the script is a single `argv` element. The /// No shell quoting is involved: the script is a single `argv` element. The
/// script finds the global `node_modules` root itself, so a Playwright installed /// script finds the global `node_modules` root and the npx cache itself, so a
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`. /// 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> { pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot( let output = exec_oneshot(
container_id, container_id,
@@ -151,15 +321,44 @@ pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, St
/// produces "detection failed". /// produces "detection failed".
const PROBE: &str = concat!( const PROBE: &str = concat!(
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#, r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#, 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 // `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. // 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#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#, 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#"out.searched=roots;"#,
r#"const res=(s)=>{for(const r of roots){try{return require.resolve(s,{paths:[r]});}catch(e){}}return null;};"#, r#"const at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#,
r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#, r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#,
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#, // 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 // `bind`/`unbind` are checked against the shipped type definitions rather
// than by loading the module: it is a static read, needs no browser, and // 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. // cannot be tripped up by a package that fails to import.
@@ -167,8 +366,40 @@ const PROBE: &str = concat!(
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#, r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
r#"const cli=res("@playwright/cli/package.json");"#, 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#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#, r#"out.cli_entry=bin(cli,j);}catch(e){}}"#,
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}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");"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
); );
@@ -201,27 +432,218 @@ mod tests {
} }
#[test] #[test]
fn a_missing_playwright_is_reported_with_where_we_looked() { fn a_missing_playwright_names_both_packages_and_where_we_looked() {
let d = parse_probe_output(&payload( let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#, r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/a1/node_modules"]}"#,
)) ))
.unwrap(); .unwrap();
assert!(!d.is_usable()); assert!(!d.is_usable());
let msg = d.blocker().unwrap(); let msg = d.blocker().unwrap();
assert!(msg.contains("npm i -D playwright"), "{}", msg); // 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); 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("/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] #[test]
fn a_playwright_without_bind_asks_for_an_upgrade() { fn a_playwright_without_bind_asks_for_an_upgrade() {
let d = parse_probe_output(&payload( let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#, 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(); .unwrap();
let msg = d.blocker().unwrap(); let msg = d.blocker().unwrap();
assert!(msg.contains("1.44.0"), "{}", msg); assert!(msg.contains("1.44.0"), "{}", msg);
assert!(msg.contains("playwright@latest"), "{}", 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] #[test]
@@ -252,6 +674,19 @@ mod tests {
assert!(err.contains("no output"), "{}", 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] #[test]
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() { 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 // It is passed straight to `node -e`; a stray single quote would only
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -63,6 +63,9 @@
pub mod commands; pub mod commands;
pub mod detect; pub mod detect;
pub mod install;
pub mod page;
pub mod popout;
pub mod proxy; pub mod proxy;
use std::collections::HashMap; use std::collections::HashMap;
@@ -466,13 +469,34 @@ async fn supervise(
let _ = kill_dashboard(&container_id, &cli_entry).await; let _ = kill_dashboard(&container_id, &cli_entry).await;
// Deregister, unless a newer session has already taken this project's slot. // Deregister, unless a newer session has already taken this project's slot.
{ let superseded = {
let mut map = sessions.lock().await; let mut map = sessions.lock().await;
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) { match map.get(&project_id) {
map.remove(&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; let enabled = manager().is_enabled(&project_id).await;
emit(&app, &project_id, &BrowserViewStatus::off(enabled)); emit(&app, &project_id, &BrowserViewStatus::off(enabled));
} }
+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);
}
}
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,7 @@ pub(crate) async fn create_container_for_project(
settings.timezone.as_deref(), settings.timezone.as_deref(),
settings.global_claude_code_settings.as_ref(), settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(), settings.default_ssh_key_path.as_deref(),
settings.ca_cert_path.as_deref(),
settings.default_git_user_name.as_deref(), settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(), settings.default_git_user_email.as_deref(),
) )
@@ -406,6 +407,7 @@ pub async fn start_project_container(
settings.timezone.as_deref(), settings.timezone.as_deref(),
settings.global_claude_code_settings.as_ref(), settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(), settings.default_ssh_key_path.as_deref(),
settings.ca_cert_path.as_deref(),
settings.default_git_user_name.as_deref(), settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(), settings.default_git_user_email.as_deref(),
).await.unwrap_or(false); ).await.unwrap_or(false);
@@ -155,6 +155,78 @@ pub async fn detect_aws_config() -> Result<Option<String>, String> {
Ok(None) Ok(None)
} }
/// What the UI shows next to a corporate CA certificate path.
///
/// Errors are returned *inside* the payload rather than as `Err` so the field
/// can render its own inline message while the user is still typing — a toast
/// per keystroke would be unusable. The same check runs again, as a hard error,
/// when the container is created.
#[derive(Debug, serde::Serialize)]
pub struct CaCertInfo {
pub exists: bool,
pub is_directory: bool,
/// How many certificate files were found.
pub cert_count: usize,
/// The names they will be installed as inside the container. Surfacing
/// these makes the silent `.pem` → `.crt` rename visible, which is the one
/// step users most often do by hand and get wrong.
pub installed_names: Vec<String>,
/// Why the path is unusable, if it is.
pub error: Option<String>,
}
#[tauri::command]
pub async fn inspect_ca_cert_path(path: String) -> Result<CaCertInfo, String> {
use crate::docker::ca_certs;
let trimmed = path.trim();
if trimmed.is_empty() {
return Ok(CaCertInfo {
exists: false,
is_directory: false,
cert_count: 0,
installed_names: Vec::new(),
error: None,
});
}
let p = std::path::Path::new(trimmed);
let exists = p.exists();
let is_directory = p.is_dir();
match ca_certs::resolve(Some(trimmed)) {
Ok(Some(resolved)) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: resolved.cert_files.len(),
installed_names: resolved
.cert_files
.iter()
.map(|f| {
ca_certs::container_cert_name(
&f.file_name().unwrap_or_default().to_string_lossy(),
)
})
.collect(),
error: None,
}),
Ok(None) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: 0,
installed_names: Vec::new(),
error: None,
}),
Err(e) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: 0,
installed_names: Vec::new(),
error: Some(e),
}),
}
}
#[tauri::command] #[tauri::command]
pub async fn list_aws_profiles() -> Result<Vec<String>, String> { pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
let mut profiles = Vec::new(); let mut profiles = Vec::new();
+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()));
}
}
+86 -1
View File
@@ -7,6 +7,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBi
use std::collections::HashMap; use std::collections::HashMap;
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
use super::ca_certs;
use super::client::get_docker; use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
@@ -792,6 +793,7 @@ pub async fn create_container(
timezone: Option<&str>, timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>, global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>, default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>, default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>, default_git_user_email: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
@@ -1029,6 +1031,34 @@ pub async fn create_container(
); );
} }
// ── Corporate CA certificates ───────────────────────────────────────────
// Resolved here (rather than down with the mounts) so the env vars land
// *before* the neutralization pass below and are seen as already-set.
//
// A bad path is a hard error, not a warning: behind a TLS-terminating
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
// and Claude Code's own API requests — each in its own confusing way. One
// message naming the path is far kinder.
//
// The values are set here rather than exported by the entrypoint because a
// terminal session is a `docker exec`, which sees the container's
// configured env and nothing the entrypoint exported. Same lesson as
// `$BROWSER` and the URL relay shim.
let effective_ca_path =
resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path);
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
if let Some(ref ca) = resolved_ca {
log::info!(
"Mounting {} corporate CA certificate(s) from {} into project {}",
ca.cert_files.len(),
ca.host_path,
project.id
);
}
for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) {
env_vars.push(format!("{}={}", key, value));
}
// ── Neutralize stale backend auth env vars ────────────────────────────── // ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container // When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container, // is recreated *from a snapshot image* committed off the previous container,
@@ -1073,11 +1103,19 @@ pub async fn create_container(
// authenticating the container with a credential the user removed. // authenticating the container with a credential the user removed.
CLAUDE_OAUTH_TOKEN_ENV, CLAUDE_OAUTH_TOKEN_ENV,
]; ];
// Same reasoning for the CA vars — `ca_env_vars` already emits them empty
// when no CA is configured, so this list is belt-and-braces for a snapshot
// committed by a build that predates the feature.
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
.iter()
.copied()
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
.collect();
let already_set: std::collections::HashSet<String> = env_vars let already_set: std::collections::HashSet<String> = env_vars
.iter() .iter()
.filter_map(|e| e.split('=').next().map(|k| k.to_string())) .filter_map(|e| e.split('=').next().map(|k| k.to_string()))
.collect(); .collect();
for key in MANAGED_AUTH_KEYS { for key in &managed_keys {
if !already_set.contains(*key) { if !already_set.contains(*key) {
env_vars.push(format!("{}=", key)); env_vars.push(format!("{}=", key));
} }
@@ -1209,6 +1247,23 @@ pub async fn create_container(
}); });
} }
// Corporate CA certificates mount (read-only staging; the entrypoint copies
// them into /usr/local/share/ca-certificates with a `.crt` name and runs
// update-ca-certificates). Mirrors /tmp/.host-ssh and /tmp/.host-aws.
//
// A directory mounts at /tmp/.host-ca; a single file mounts at
// /tmp/.host-ca/<name>.crt so the entrypoint always sees a directory and
// the certificate keeps a recognisable name. Docker creates the parent.
if let Some(ref ca) = resolved_ca {
mounts.push(Mount {
target: Some(ca.mount_target.clone()),
source: Some(ca.host_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
// AWS config mount (read-only) // AWS config mount (read-only)
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set // Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
let should_mount_aws = if project.backend == Backend::Bedrock { let should_mount_aws = if project.backend == Backend::Bedrock {
@@ -1306,6 +1361,13 @@ pub async fn create_container(
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)); compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
labels.insert("triple-c.instructions-fingerprint".to_string(), labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
// Written unconditionally, even when empty — `container_needs_recreation`
// is label-based and never diffs env or mounts, so without this a changed
// CA path would silently do nothing until some unrelated setting forced a
// rebuild. The fingerprint covers the certificate *bytes* as well as the
// path, so swapping a rotated CA in at the same location is caught too.
labels.insert("triple-c.ca-fingerprint".to_string(),
ca_certs::compute_ca_fingerprint(effective_ca_path));
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string()); labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string()); labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
labels.insert("triple-c.git-token-hash".to_string(), labels.insert("triple-c.git-token-hash".to_string(),
@@ -1911,6 +1973,7 @@ pub async fn container_needs_recreation(
timezone: Option<&str>, timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>, global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>, default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>, default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>, default_git_user_email: Option<&str>,
) -> Result<bool, String> { ) -> Result<bool, String> {
@@ -2078,6 +2141,28 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── Corporate CA certificates ────────────────────────────────────────
// Both the resolved path and the certificate contents, so replacing a
// rotated CA at the same path recreates the container — the copy inside
// the container is made once, at start, and nothing else would notice.
//
// A container predating this feature has no label, i.e. "", which is also
// what an unconfigured CA fingerprints as — so existing installs are not
// churned until a CA is actually set.
let expected_ca_fp = ca_certs::compute_ca_fingerprint(resolve_with_global(
project.ca_cert_path.as_deref(),
default_ca_cert_path,
));
let container_ca_fp = get_label("triple-c.ca-fingerprint").unwrap_or_default();
if container_ca_fp != expected_ca_fp {
log::info!(
"Corporate CA certificate mismatch (container={:?}, expected={:?})",
container_ca_fp,
expected_ca_fp
);
return Ok(true);
}
// ── Git settings (label-based to avoid stale snapshot env vars) ───── // ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.as_deref() let expected_git_name = project.git_user_name.as_deref()
.or(default_git_user_name) .or(default_git_user_name)
+4
View File
@@ -1,3 +1,4 @@
pub mod ca_certs;
pub mod client; pub mod client;
pub mod container; pub mod container;
pub mod image; pub mod image;
@@ -23,3 +24,6 @@ pub use exec::*;
pub use legacy_cleanup::*; pub use legacy_cleanup::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use migration::*; 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.
+21
View File
@@ -328,6 +328,14 @@ pub fn run() {
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// This handler fires for *every* window, and what follows stops
// containers and exits the process. Only the main window means
// that. Secondary windows — the browser view's pop-out — are
// closed and reopened freely and must just close.
if window.label() != "main" {
return;
}
let state = window.state::<AppState>(); let state = window.state::<AppState>();
let lifecycle = state.lifecycle.clone(); let lifecycle = state.lifecycle.clone();
@@ -426,6 +434,18 @@ pub fn run() {
browser_view::commands::set_browser_view_enabled, browser_view::commands::set_browser_view_enabled,
browser_view::commands::get_browser_view_status, browser_view::commands::get_browser_view_status,
browser_view::commands::check_browser_view_support, browser_view::commands::check_browser_view_support,
browser_view::commands::install_browser_view_support,
browser_view::commands::install_browser_view_browser,
browser_view::commands::open_browser_view_popout,
browser_view::commands::close_browser_view_popout,
browser_view::commands::get_browser_view_popout_state,
browser_view::commands::set_browser_view_popout_always_on_top,
browser_view::commands::open_page_in_container_browser,
browser_view::commands::set_container_page_viewport,
browser_view::commands::get_container_page_state,
browser_view::commands::close_container_page,
browser_view::commands::set_browser_view_match_window,
browser_view::commands::get_browser_view_match_window,
// Shared Claude Code auth token // Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code, commands::auth_token_commands::submit_claude_token_code,
@@ -437,6 +457,7 @@ pub fn run() {
commands::settings_commands::update_settings, commands::settings_commands::update_settings,
commands::settings_commands::pull_image, commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config, commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles, commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone, commands::settings_commands::detect_host_timezone,
// Terminal // Terminal
+9
View File
@@ -87,6 +87,14 @@ pub struct GlobalOpenAiCompatibleSettings {
pub struct AppSettings { pub struct AppSettings {
#[serde(default)] #[serde(default)]
pub default_ssh_key_path: Option<String>, pub default_ssh_key_path: Option<String>,
/// Path to the organisation's root CA — a single certificate file or a
/// directory of them. Mounted read-only into every container, which then
/// installs it into the system trust store, Node's `NODE_EXTRA_CA_CERTS`,
/// Python's `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database.
/// Required when the host sits behind a TLS-terminating corporate proxy.
/// Overridden per project by `Project::ca_cert_path`.
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(default)] #[serde(default)]
pub default_git_user_name: Option<String>, pub default_git_user_name: Option<String>,
#[serde(default)] #[serde(default)]
@@ -197,6 +205,7 @@ impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
default_ssh_key_path: None, default_ssh_key_path: None,
ca_cert_path: None,
default_git_user_name: None, default_git_user_name: None,
default_git_user_email: None, default_git_user_email: None,
docker_socket_path: None, docker_socket_path: None,
+8
View File
@@ -166,6 +166,13 @@ pub struct Project {
#[serde(default)] #[serde(default)]
pub permission_mode: Option<PermissionMode>, pub permission_mode: Option<PermissionMode>,
pub ssh_key_path: Option<String>, pub ssh_key_path: Option<String>,
/// Per-project override for the corporate CA certificate path (file or
/// directory). Blank falls back to `AppSettings::ca_cert_path`.
///
/// `#[serde(default)]` rather than a required field: every project stored
/// before this existed must keep loading.
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(skip_serializing, default)] #[serde(skip_serializing, default)]
pub git_token: Option<String>, pub git_token: Option<String>,
pub git_user_name: Option<String>, pub git_user_name: Option<String>,
@@ -363,6 +370,7 @@ impl Project {
full_permissions: false, full_permissions: false,
permission_mode: None, permission_mode: None,
ssh_key_path: None, ssh_key_path: None,
ca_cert_path: None,
git_token: None, git_token: None,
git_user_name: None, git_user_name: None,
git_user_email: None, git_user_email: None,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C", "productName": "Triple-C",
"version": "0.3.0", "version": "0.4.0",
"identifier": "com.triple-c.desktop", "identifier": "com.triple-c.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+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");
}
});
});
+336 -123
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
@@ -18,6 +18,9 @@ interface ContextMenuState {
y: number; y: number;
} }
/** Pixels of horizontal travel before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = { const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
@@ -28,22 +31,46 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
/** /**
* One strip for both main-area tab kinds: Project Home views () and * One strip for both main-area tab kinds: Project Home views () and
* terminals (). * terminals ().
*
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop see
* `pointerProps` for why neither of the two obvious alternatives works.
* `Ctrl+Shift+←/→` does the same thing without a mouse.
*/ */
export default function MainTabs() { export default function MainTabs() {
const { sessions, close } = useTerminal(); const { sessions, close } = useTerminal();
const { projects, update } = useProjects(); const { projects, update } = useProjects();
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState( const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
useShallow((s) => ({ useShallow((s) => ({
tabOrder: s.tabOrder, tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey, activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey, setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab, closeHomeTab: s.closeHomeTab,
moveTab: s.moveTab,
})), })),
); );
const [menu, setMenu] = useState<ContextMenuState | null>(null); const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null); const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState(""); const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
/** The tab being dragged, and the slot it would drop into. */
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
/** Where the dragged tab is drawn, and how it looked when the drag started. */
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
null,
);
const stripRef = useRef<HTMLDivElement>(null);
/** A press that has not yet moved far enough to be a drag. */
const pending = useRef<{
key: string;
startX: number;
dragging: boolean;
offsetX: number;
width: number;
height: number;
top: number;
} | null>(null);
const suppressClick = useRef(false);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
@@ -63,6 +90,21 @@ export default function MainTabs() {
} }
}, [renamingId]); }, [renamingId]);
// Escape abandons a drag — the one affordance a pointer-event drag has to
// supply for itself, since the OS is not running this one.
useEffect(() => {
if (!dragKey) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
pending.current = null;
setDragKey(null);
setDropIndex(null);
setGhost(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [dragKey]);
if (tabOrder.length === 0) { if (tabOrder.length === 0) {
return ( return (
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10"> <div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
@@ -135,136 +177,307 @@ export default function MainTabs() {
} }
}; };
const tabClass = (active: boolean) => const tabClass = (active: boolean, dragging: boolean) =>
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${ `flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${
active active
? "bg-[var(--bg-primary)] text-[var(--text-primary)]" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`; }${dragging ? " opacity-40" : ""}`;
/**
* What a tab reads as, for the dragged copy. Same sources the tab itself
* uses a ghost showing a different name from the tab it came from would be
* worse than no ghost.
*/
const tabLabel = (key: string): string => {
if (isHomeTab(key)) {
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
}
const session = sessions.find((s) => s.id === tabKeyId(key));
if (!session) return "";
const custom = getCustomName(session.projectId, session.id);
return custom
? `${session.projectName}: ${custom}`
: (session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
};
const endDrag = () => {
pending.current = null;
setDragKey(null);
setDropIndex(null);
setGhost(null);
};
/**
* Which slot the pointer is currently over, as an insertion index into
* `tabOrder`.
*
* Measured from the tabs actually on screen rather than from the event's
* target, so the answer is the same whatever the pointer happens to be over
* including the drop marker itself, and including a `tabOrder` entry whose
* session has already gone and which therefore renders nothing.
*/
const dropIndexAt = (clientX: number): number => {
const strip = stripRef.current;
if (!strip) return tabOrder.length;
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const rect = el.getBoundingClientRect();
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
}
return tabOrder.length;
};
/**
* Dragging is done with pointer events, not HTML5 drag-and-drop.
*
* Two reasons, both load-bearing. Tauri's `dragDropEnabled` which the
* terminal needs left on, because only the native drag-drop event carries
* dropped *file paths* blocks HTML5 drag inside the webview on Windows, so
* an HTML5 implementation is simply dead there. And an HTML5 drag carries a
* `DataTransfer`: released over any text field in the app, the default
* handler types the payload into it.
*/
const pointerProps = (key: string, renaming: boolean) => ({
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
// Left button only, never from the close button, and never while the
// rename input is up — that drag is a text selection.
if (e.button !== 0 || renaming) return;
if ((e.target as HTMLElement).closest("button, input")) return;
const rect = e.currentTarget.getBoundingClientRect();
pending.current = {
key,
startX: e.clientX,
dragging: false,
// Where inside the tab the pointer grabbed it, so the ghost sits under
// the cursor exactly where the real tab was — the thing that makes a
// drag feel like moving an object rather than nudging a setting.
offsetX: e.clientX - rect.left,
width: rect.width,
height: rect.height,
top: rect.top,
};
e.currentTarget.setPointerCapture?.(e.pointerId);
},
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
const drag = pending.current;
if (!drag) return;
// A few pixels of slop, so a click that trembles stays a click.
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
drag.dragging = true;
setDragKey(drag.key);
setDropIndex(dropIndexAt(e.clientX));
setGhost({
x: e.clientX - drag.offsetX,
y: drag.top,
label: tabLabel(drag.key),
icon: isHomeTab(drag.key) ? "⌂" : "▣",
});
},
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
const drag = pending.current;
e.currentTarget.releasePointerCapture?.(e.pointerId);
if (!drag?.dragging) {
pending.current = null;
return; // a plain click: leave it to `onClick` to select the tab
}
const to = dropIndexAt(e.clientX);
const from = tabOrder.indexOf(drag.key);
// `to` is a slot in the strip as it looks *now*; `moveTab` places the tab
// after pulling it out, so every slot past its own shifts down one.
if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to);
// The click that follows this pointerup is the drag's, not a selection.
suppressClick.current = true;
endDrag();
},
onPointerCancel: endDrag,
});
/** A drag in progress swallows the click it ends with. */
const activateTab = (key: string) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setActiveTabKey(key);
};
const dropMarker = (
<div
aria-hidden="true"
data-testid="tab-drop-marker"
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0 pointer-events-none"
/>
);
const renderTab = (key: string, index: number) => {
const active = activeTabKey === key;
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
data-tab-index={index}
onClick={() => activateTab(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
{...pointerProps(key, false)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
{project.name}
</span>
<ProjectStatusIndicator status={project.status} iconOnly />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeHomeTab(projectId);
}}
aria-label={`Close ${project.name} home tab`}
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
const sessionId = tabKeyId(key);
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
data-tab-index={index}
onClick={() => activateTab(terminalTabKey(session.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(terminalTabKey(session.id));
}
}}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
{...pointerProps(key, isRenaming)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label="Rename tab"
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
) : (
<span className="truncate max-w-[180px]" title={displayLabel}>
{displayLabel}
</span>
)}
{badge && (
<span
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
title={`Permission mode: ${badge.text}`}
>
{badge.text}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
aria-label={`Close ${displayLabel}`}
title="Close terminal"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
};
// The marker goes before the first tab that is *actually on screen* at or
// past the drop slot. Addressing it by raw index would lose it whenever a
// `tabOrder` entry renders nothing — the window between a session ending and
// the store dropping its key — leaving the drag with no visible target.
let markerPending = dragKey !== null && dropIndex !== null;
return ( return (
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs"> <div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
{tabOrder.map((key) => { {tabOrder.map((key, index) => {
const active = activeTabKey === key; const tab = renderTab(key, index);
if (!tab) return null;
if (isHomeTab(key)) { const marker = markerPending && index >= (dropIndex ?? 0);
const projectId = tabKeyId(key); if (marker) markerPending = false;
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
return (
<div
key={key}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
{project.name}
</span>
<ProjectStatusIndicator status={project.status} iconOnly />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeHomeTab(projectId);
}}
aria-label={`Close ${project.name} home tab`}
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
const sessionId = tabKeyId(key);
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
return ( return (
<div <Fragment key={key}>
key={key} {marker && dropMarker}
role="tab" {tab}
aria-selected={active} </Fragment>
tabIndex={0}
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(terminalTabKey(session.id));
}
}}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label="Rename tab"
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
) : (
<span className="truncate max-w-[180px]" title={displayLabel}>
{displayLabel}
</span>
)}
{badge && (
<span
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
title={`Permission mode: ${badge.text}`}
>
{badge.text}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
aria-label={`Close ${displayLabel}`}
title="Close terminal"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
); );
})} })}
{/* The empty run after the last tab is a drop target too it is where
the hand naturally goes to say "put it at the end". */}
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
{ghost && (
// A copy of the tab, following the pointer. Without it the only
// feedback is a dimmed source and a thin line, which reads as "some
// setting changed" rather than "I am holding this tab".
<div
aria-hidden="true"
data-testid="tab-drag-ghost"
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none select-none"
style={{
left: ghost.x,
top: ghost.y,
boxShadow: "var(--shadow-overlay)",
opacity: 0.9,
}}
>
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
<span className="truncate max-w-[180px]">{ghost.label}</span>
</div>
)}
{menu && (() => { {menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId); const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session const hasCustom = session
@@ -1,23 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import BrowserTab from "./BrowserTab"; import BrowserTab from "./BrowserTab";
import type { BrowserViewStatus, Project } from "../../../lib/types"; import type {
BrowserSetupOutcome,
BrowserViewStatus,
PlaywrightDetection,
Project,
} from "../../../lib/types";
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>(); const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>(); const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
const getBrowserViewPopoutState =
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
const openPageInContainerBrowser =
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
const pushToast = vi.fn(); const pushToast = vi.fn();
const setContainerProgress = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({ vi.mock("../../../lib/tauri-commands", () => ({
getBrowserViewStatus: () => getBrowserViewStatus(), getBrowserViewStatus: () => getBrowserViewStatus(),
setBrowserViewEnabled: () => setBrowserViewEnabled(), setBrowserViewEnabled: () => setBrowserViewEnabled(),
checkBrowserViewSupport: () => checkBrowserViewSupport(),
installBrowserViewSupport: () => installBrowserViewSupport(),
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
setBrowserViewPopoutAlwaysOnTop(id, onTop),
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
openPageInContainerBrowser(id, url, w, h),
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
})); }));
vi.mock("@tauri-apps/api/event", () => ({ vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}), listen: vi.fn(async () => () => {}),
})); }));
const storeState = {
pushToast,
setContainerProgress,
containerProgress: {} as Record<string, string>,
};
vi.mock("../../../store/appState", () => ({ vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }), useAppState: (selector: (s: unknown) => unknown) => selector(storeState),
})); }));
const OFF: BrowserViewStatus = { const OFF: BrowserViewStatus = {
@@ -31,6 +67,38 @@ const OFF: BrowserViewStatus = {
message: null, message: null,
}; };
const NOTHING: PlaywrightDetection = {
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
playwright_cli: null,
has_bind: false,
cli_version: null,
cli_entry: null,
browsers: [],
chrome_channel: null,
chromium_executable: null,
chromium_executable_exists: false,
script_playwright_version: null,
script_chromium_executable: null,
script_chromium_executable_exists: false,
searched: [
"/workspace",
"/usr/lib/node_modules",
"/home/claude/.npm/_npx/9f3a/node_modules",
],
};
const READY: PlaywrightDetection = {
...NOTHING,
playwright_version: "1.62.1",
playwright_path: "/workspace/node_modules/playwright-core/package.json",
playwright_cli: "/workspace/node_modules/playwright-core/cli.js",
has_bind: true,
cli_version: "0.1.18",
cli_entry: "/workspace/node_modules/@playwright/cli/playwright-cli.js",
};
const project: Project = { const project: Project = {
id: "p1", id: "p1",
name: "api-server", name: "api-server",
@@ -63,26 +131,64 @@ const project: Project = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
storeState.containerProgress = {};
getBrowserViewStatus.mockResolvedValue(OFF); getBrowserViewStatus.mockResolvedValue(OFF);
checkBrowserViewSupport.mockResolvedValue(READY);
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
openBrowserViewPopout.mockResolvedValue(undefined);
closeBrowserViewPopout.mockResolvedValue(undefined);
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
setBrowserViewMatchWindow.mockResolvedValue(undefined);
getBrowserViewMatchWindow.mockResolvedValue(false);
openPageInContainerBrowser.mockResolvedValue({ error: null });
}); });
const LIVE: BrowserViewStatus = {
...OFF,
enabled: true,
state: "running",
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
host_port: 47820,
container_port: 39321,
started_at: "2026-08-09T10:00:00Z",
};
/** Render with the view already live, which is the only state that pops out. */
async function renderLive() {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
await screen.findByTitle("Playwright browser view for api-server");
}
describe("BrowserTab", () => { describe("BrowserTab", () => {
it("does not offer to start anything while the container is stopped", async () => { it("does not offer to start anything while the container is stopped", async () => {
render(<BrowserTab project={{ ...project, status: "stopped" }} active />); render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
expect(await screen.findByText(/container isnt running/i)).toBeInTheDocument(); expect(await screen.findByText(/container isnt running/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull(); expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
expect(getBrowserViewStatus).not.toHaveBeenCalled(); expect(getBrowserViewStatus).not.toHaveBeenCalled();
expect(checkBrowserViewSupport).not.toHaveBeenCalled();
}); });
it("starts off, and never starts a view without being asked", async () => { it("starts off, and never starts a view or installs anything without being asked", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
render(<BrowserTab project={project} active />); render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled()); await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
expect(screen.getByText("Off")).toBeInTheDocument(); expect(screen.getByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull(); expect(screen.queryByTitle(/browser view for/i)).toBeNull();
expect(setBrowserViewEnabled).not.toHaveBeenCalled(); expect(setBrowserViewEnabled).not.toHaveBeenCalled();
// Probing is read-only and expected; installing is a mutation and is not.
await waitFor(() => expect(checkBrowserViewSupport).toHaveBeenCalled());
expect(installBrowserViewSupport).not.toHaveBeenCalled();
expect(installBrowserViewBrowser).not.toHaveBeenCalled();
}); });
it("shows the live pane, pointed at loopback with a token, once started", async () => { it("shows the live pane, pointed at loopback with a token, once started", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockResolvedValue({ setBrowserViewEnabled.mockResolvedValue({
...OFF, ...OFF,
enabled: true, enabled: true,
@@ -109,27 +215,121 @@ describe("BrowserTab", () => {
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
}); });
it("offers setup before the user hits a wall, naming what is missing", async () => {
checkBrowserViewSupport.mockResolvedValue(NOTHING);
render(<BrowserTab project={project} active />);
// No Start attempt was needed to learn this.
expect(await screen.findByRole("button", { name: /set up playwright/i })).toBeInTheDocument();
expect(screen.getByText(/Missing: playwright, @playwright\/cli/)).toBeInTheDocument();
// The npx cache is shown among the searched roots — that is where an
// MCP-installed Playwright actually lives.
expect(screen.getByText(/_npx\/9f3a\/node_modules/)).toBeInTheDocument();
// A browser can't be installed before Playwright is.
expect(screen.getByRole("button", { name: /install chromium/i })).toBeDisabled();
});
it("installs Playwright on request and updates itself from the fresh probe", async () => {
checkBrowserViewSupport.mockResolvedValue(NOTHING);
installBrowserViewSupport.mockResolvedValue({
detection: READY,
log: "added 5 packages in 3s",
browser_launched: null,
warning: "Playwright is installed, but this container has no browser to drive yet.",
});
render(<BrowserTab project={project} active />);
const button = await screen.findByRole("button", { name: /set up playwright/i });
await act(async () => {
fireEvent.click(button);
});
await waitFor(() => expect(installBrowserViewSupport).toHaveBeenCalled());
// The pane re-rendered from the returned probe — no reopening the tab.
expect(await screen.findByText("1.62.1")).toBeInTheDocument();
// Stated in the warning box, and again in the pane's own summary line.
expect(screen.getAllByText(/no browser to drive yet/).length).toBeGreaterThan(0);
// And the browser buttons are now live.
expect(screen.getByRole("button", { name: /install chromium/i })).toBeEnabled();
expect(screen.getByRole("button", { name: /install chrome channel/i })).toBeEnabled();
// The progress line is always cleared, whatever happened.
expect(setContainerProgress).toHaveBeenCalledWith("p1", null);
});
it("says which browser is for which caller, and states the size first", async () => {
checkBrowserViewSupport.mockResolvedValue(READY);
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/several hundred mb/i)).toBeInTheDocument();
// The copy is broken across a <code> element, so match the container.
expect(
screen.getByText((_, el) =>
(el?.textContent ?? "").includes("@playwright/mcp") &&
(el?.textContent ?? "").includes("asks for") &&
el?.tagName.toLowerCase() === "li",
),
).toBeInTheDocument();
expect(screen.getByText(/roughly 150 mb/i)).toBeInTheDocument();
});
it("installs the chrome channel when that is the one asked for", async () => {
checkBrowserViewSupport.mockResolvedValue(READY);
installBrowserViewBrowser.mockResolvedValue({
detection: { ...READY, chrome_channel: "/usr/bin/google-chrome-stable" },
log: "Installing google-chrome-stable",
browser_launched: true,
warning: null,
});
render(<BrowserTab project={project} active />);
const button = await screen.findByRole("button", { name: /install chrome channel/i });
await act(async () => {
fireEvent.click(button);
});
await waitFor(() =>
expect(installBrowserViewBrowser).toHaveBeenCalledWith("p1", "chrome"),
);
// Shown as the step's "done" line and again in the diagnostics table.
await waitFor(() =>
expect(screen.getAllByText(/google-chrome-stable/).length).toBeGreaterThan(0),
);
});
it("reports an install failure with the real command output", async () => {
checkBrowserViewSupport.mockResolvedValue(NOTHING);
installBrowserViewSupport.mockRejectedValue(
"npm couldn't install Playwright in this container (exit 1).\n\nnpm said:\nEACCES: permission denied",
);
render(<BrowserTab project={project} active />);
const button = await screen.findByRole("button", { name: /set up playwright/i });
await act(async () => {
fireEvent.click(button);
});
expect(await screen.findByText(/EACCES: permission denied/)).toBeInTheDocument();
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: "error" }),
);
expect(setContainerProgress).toHaveBeenCalledWith("p1", null);
});
it("explains precisely what is missing instead of spinning", async () => { it("explains precisely what is missing instead of spinning", async () => {
checkBrowserViewSupport.mockRejectedValue("container busy");
getBrowserViewStatus.mockResolvedValue({ getBrowserViewStatus.mockResolvedValue({
...OFF, ...OFF,
enabled: true, enabled: true,
state: "unavailable", state: "unavailable",
message: message:
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.", "Playwright isn't installed in this container. Two packages are needed: `playwright` and `@playwright/cli`.",
detection: { detection: NOTHING,
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
has_bind: false,
cli_version: null,
cli_entry: null,
searched: ["/workspace", "/usr/lib/node_modules"],
},
}); });
render(<BrowserTab project={project} active />); render(<BrowserTab project={project} active />);
expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument(); expect(await screen.findByText(/Two packages are needed/)).toBeInTheDocument();
expect(screen.getByText("Unavailable")).toBeInTheDocument(); expect(screen.getByText("Unavailable")).toBeInTheDocument();
// The probe's findings are shown, so the user can see why. // The probe's findings are shown, so the user can see why.
expect(screen.getByText("22.11.0")).toBeInTheDocument(); expect(screen.getByText("22.11.0")).toBeInTheDocument();
@@ -139,6 +339,7 @@ describe("BrowserTab", () => {
}); });
it("surfaces a start failure rather than leaving the pane blank", async () => { it("surfaces a start failure rather than leaving the pane blank", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockRejectedValue("container went away"); setBrowserViewEnabled.mockRejectedValue("container went away");
render(<BrowserTab project={project} active />); render(<BrowserTab project={project} active />);
@@ -155,6 +356,7 @@ describe("BrowserTab", () => {
}); });
it("stops the view when asked", async () => { it("stops the view when asked", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue({ getBrowserViewStatus.mockResolvedValue({
...OFF, ...OFF,
enabled: true, enabled: true,
@@ -175,4 +377,198 @@ describe("BrowserTab", () => {
expect(await screen.findByText("Off")).toBeInTheDocument(); expect(await screen.findByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull(); expect(screen.queryByTitle(/browser view for/i)).toBeNull();
}); });
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
// The cache is full and every script fails \u2014 "install a browser" alone
// would read as nonsense, so the copy has to say which copy wants what.
checkBrowserViewSupport.mockResolvedValue({
...READY,
browsers: ["chromium-1237"],
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
chromium_executable_exists: true,
script_playwright_version: "1.62.1",
script_chromium_executable:
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
script_chromium_executable_exists: false,
});
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
// Both revisions appear in the explanation: what is installed, and what
// the failing copy actually wants.
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
});
it("does not call an unanswered probe a skew", async () => {
// A container older than these fields omits them; unknown is not broken.
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
});
it("only offers a window of its own once there is something to watch", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
});
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
// The window is showing it now — a second copy here would be a second
// cursor on the same page.
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
// Still live, and still stoppable from the tab.
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("puts the view back in the tab when the window is closed from here", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
});
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
it("pins the window on top on request", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
// The accessible name is the visible text, as with every other Toggle.
fireEvent.click(screen.getByRole("switch", { name: "Keep on top" }));
});
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
});
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
// The window belongs to the backend, so remounting the pane has to read its
// state back — otherwise the pane would render an iframe alongside it.
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
// The pin is read from the window too — the pane is unmounted every time
// another sub-tab is selected, so remembering it would show Off over a
// window that is still floating on top.
expect(screen.getByRole("switch", { name: "Keep on top" })).toBeChecked();
});
it("never mounts the iframe before the window's state is known", async () => {
// The status and the pop-out state are two separate round trips. If the
// status wins the race, guessing "not popped out" would flash a second
// viewer onto a browser the window is already driving.
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
let answer: (s: { open: boolean; always_on_top: boolean }) => void = () => {};
getBrowserViewPopoutState.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
render(<BrowserTab project={project} active />);
await waitFor(() => expect(screen.getByText("Live")).toBeInTheDocument());
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
await act(async () => {
answer({ open: false, always_on_top: false });
});
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
it("opens a page in the containers browser at the chosen viewport", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
});
fireEvent.change(screen.getByLabelText(/^URL$/i), {
target: { value: "http://localhost:5173" },
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
});
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
"p1",
"http://localhost:5173",
1920,
1080,
);
});
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
});
fireEvent.change(screen.getByLabelText(/^URL$/i), {
target: { value: "file:///etc/passwd" },
});
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
});
it("offers match-window only once the view is in its own window", async () => {
await renderLive();
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
});
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
});
it("says why the window wouldnt open instead of pretending it did", async () => {
await renderLive();
openBrowserViewPopout.mockRejectedValue("no display");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: "error", detail: "no display" }),
);
// The view is still in the tab, where it was.
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
}); });
+621 -25
View File
@@ -1,17 +1,34 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { import type {
BrowserInstallTarget,
BrowserSetupOutcome,
BrowserViewChangedEvent, BrowserViewChangedEvent,
BrowserViewPopoutChangedEvent,
BrowserViewStatus, BrowserViewStatus,
PlaywrightDetection,
Project, Project,
} from "../../../lib/types"; } from "../../../lib/types";
import { import {
checkBrowserViewSupport,
closeBrowserViewPopout,
getBrowserViewStatus, getBrowserViewStatus,
installBrowserViewBrowser,
installBrowserViewSupport,
getBrowserViewMatchWindow,
getBrowserViewPopoutState,
openBrowserViewPopout,
openPageInContainerBrowser,
setBrowserViewEnabled, setBrowserViewEnabled,
setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands"; } from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection";
import Button from "../../ui/Button"; import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator"; import StatusIndicator from "../../ui/StatusIndicator";
import Toggle from "../../ui/Toggle";
interface Props { interface Props {
project: Project; project: Project;
@@ -29,6 +46,9 @@ const OFF: BrowserViewStatus = {
message: null, message: null,
}; };
/** Which install is in flight. `null` means none — nothing installs itself. */
type SetupJob = null | "packages" | BrowserInstallTarget;
/** /**
* Watch and take over the browser Claude is driving with Playwright inside * Watch and take over the browser Claude is driving with Playwright inside
* the container. * the container.
@@ -38,6 +58,12 @@ const OFF: BrowserViewStatus = {
* loopback. Nothing starts until the user asks: this is remote control of a * loopback. Nothing starts until the user asks: this is remote control of a
* browser in a privileged sandbox, so it is off by default and opted into per * browser in a privileged sandbox, so it is off by default and opted into per
* project, exactly like the auth bridge. * project, exactly like the auth bridge.
*
* The same rule, harder, applies to setup. Opening this tab *probes* the
* container (one `node -e`, read-only) so the pane can say what is missing
* before the user asks for a view but it never installs anything. Installing
* packages and downloading a browser are container mutations measured in
* hundreds of megabytes; both are separate, labelled, user-pressed buttons.
*/ */
export default function BrowserTab({ project, active }: Props) { export default function BrowserTab({ project, active }: Props) {
const [status, setStatus] = useState<BrowserViewStatus>(OFF); const [status, setStatus] = useState<BrowserViewStatus>(OFF);
@@ -45,7 +71,26 @@ export default function BrowserTab({ project, active }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Bumped to force the iframe to reload without changing its src. */ /** Bumped to force the iframe to reload without changing its src. */
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
/** Last read-only probe of the container, for the setup panel. */
const [detection, setDetection] = useState<PlaywrightDetection | null>(null);
const [job, setJob] = useState<SetupJob>(null);
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
const [setupError, setSetupError] = useState<string | null>(null);
/**
* Whether the view is in its own window instead of this pane, and whether
* that window is pinned. `null` means "not asked yet" a distinct state from
* "not popped out", because rendering the iframe on a guess is what puts a
* second viewer on the browser.
*/
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
const [onTop, setOnTop] = useState(false);
/** The "open a page" dialog, and the request it is running. */
const [matchWindow, setMatchWindow] = useState(false);
const [askPage, setAskPage] = useState(false);
const [openingPage, setOpeningPage] = useState(false);
const pushToast = useAppState((s) => s.pushToast); const pushToast = useAppState((s) => s.pushToast);
const setContainerProgress = useAppState((s) => s.setContainerProgress);
const progress = useAppState((s) => s.containerProgress[project.id]);
const running = project.status === "running"; const running = project.status === "running";
// The backend is the source of truth: it emits whenever a view starts or is // The backend is the source of truth: it emits whenever a view starts or is
@@ -72,11 +117,45 @@ export default function BrowserTab({ project, active }: Props) {
return () => dispose?.(); return () => dispose?.();
}, [projectId]); }, [projectId]);
// The window is the backend's, not this component's: it survives the tab
// being closed, the pane being unmounted and the view being torn down from
// elsewhere. So its state is listened for, never assumed.
useEffect(() => {
let dispose: (() => void) | undefined;
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
if (event.payload.project_id === projectId && mounted.current) {
setPoppedOut(event.payload.open);
setOnTop(event.payload.always_on_top);
}
}).then((un) => {
if (mounted.current) dispose = un;
else un();
});
return () => dispose?.();
}, [projectId]);
useEffect(() => { useEffect(() => {
if (!active || !running) return; if (!active || !running) return;
getBrowserViewPopoutState(projectId)
.then((s) => {
if (!mounted.current) return;
setPoppedOut(s.open);
setOnTop(s.always_on_top);
})
// Unreachable in practice, but a pane stuck at "not asked yet" would
// never show the view at all — so fail towards the tab.
.catch(() => mounted.current && setPoppedOut(false));
getBrowserViewMatchWindow(projectId)
.then((on) => mounted.current && setMatchWindow(on))
.catch(() => {});
getBrowserViewStatus(projectId) getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s)) .then((s) => mounted.current && setStatus(s))
.catch(() => {}); .catch(() => {});
// Read-only. This is what lets the pane offer setup before the user hits a
// wall, and it is why a "not installed" answer is never stale.
checkBrowserViewSupport(projectId)
.then((d) => mounted.current && setDetection(d))
.catch(() => {});
}, [active, projectId, running]); }, [active, projectId, running]);
const toggle = useCallback( const toggle = useCallback(
@@ -101,8 +180,151 @@ export default function BrowserTab({ project, active }: Props) {
[projectId, pushToast], [projectId, pushToast],
); );
// A stopped container can't be hosting a browser, so say that plainly rather /**
// than offering a control that would only fail. * Pop the view out, or pull it back.
*
* Both are window operations only the viewer keeps running either way so
* this is cheap enough to toggle freely and never interrupts what the agent
* is doing in the browser.
*/
const popOut = useCallback(async () => {
try {
await openBrowserViewPopout(projectId, onTop);
if (mounted.current) setPoppedOut(true);
} catch (e) {
pushToast({
kind: "error",
message: "Could not open the browser in its own window",
detail: String(e),
});
}
}, [projectId, onTop, pushToast]);
const popIn = useCallback(async () => {
try {
await closeBrowserViewPopout(projectId);
if (mounted.current) setPoppedOut(false);
} catch (e) {
pushToast({
kind: "error",
message: "Could not close the browser window",
detail: String(e),
});
}
}, [projectId, pushToast]);
const toggleOnTop = useCallback(
async (next: boolean) => {
setOnTop(next);
try {
await setBrowserViewPopoutAlwaysOnTop(projectId, next);
} catch (e) {
if (mounted.current) setOnTop(!next);
pushToast({
kind: "error",
message: "Could not change the window's stacking",
detail: String(e),
});
}
},
[projectId, pushToast],
);
/**
* Open a URL in a browser inside the container.
*
* The pane only ever *watched* browsers something else published; this is the
* one action that opens one. It also means the page can be resized later
* whoever launches a bound browser is the only process that can drive it.
*/
const openPage = useCallback(
async (url: string, width: number, height: number) => {
setOpeningPage(true);
try {
const result = await openPageInContainerBrowser(projectId, url, width, height);
if (!mounted.current) return;
setAskPage(false);
if (result.error) {
pushToast({ kind: "error", message: "The page didnt open", detail: result.error });
} else {
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
}
} catch (e) {
pushToast({
kind: "error",
message: "Could not open the page in the containers browser",
detail: String(e),
});
} finally {
if (mounted.current) setOpeningPage(false);
}
},
[projectId, pushToast],
);
const toggleMatchWindow = useCallback(
async (next: boolean) => {
setMatchWindow(next);
try {
await setBrowserViewMatchWindow(projectId, next);
} catch (e) {
if (mounted.current) setMatchWindow(!next);
pushToast({
kind: "error",
message: "Could not match the page to the window",
detail: String(e),
});
}
},
[projectId, pushToast],
);
/** Run one install. Every path clears the progress line it started. */
const install = useCallback(
async (which: Exclude<SetupJob, null>) => {
setJob(which);
setSetupError(null);
setOutcome(null);
try {
const result =
which === "packages"
? await installBrowserViewSupport(projectId)
: await installBrowserViewBrowser(projectId, which);
if (!mounted.current) return;
// The command re-probes, so the pane updates itself — no reopening the
// tab, no second button to press.
setDetection(result.detection);
setOutcome(result);
if (result.warning) {
// Not an error — the step did what it said — but the caveat is the
// part that decides whether the browser will actually work.
pushToast({
kind: "info",
message: "Setup finished, with something to know",
detail: result.warning,
});
} else {
pushToast({
kind: "success",
message:
which === "packages" ? "Playwright installed" : `${which} installed and verified`,
});
}
} catch (e) {
const detail = String(e);
if (mounted.current) setSetupError(detail);
pushToast({ kind: "error", message: "Setup failed", detail });
} finally {
setContainerProgress(projectId, null);
if (mounted.current) setJob(null);
}
},
[projectId, pushToast, setContainerProgress],
);
// A stopped container can't be hosting a browser — and can't be installed
// into either, so say that plainly rather than offering controls that would
// only fail.
if (!running) { if (!running) {
return ( return (
<Explainer title="The container isnt running."> <Explainer title="The container isnt running.">
@@ -113,6 +335,18 @@ export default function BrowserTab({ project, active }: Props) {
} }
const live = status.state === "running" && status.url; const live = status.state === "running" && status.url;
// Prefer the probe: it is the fresher of the two, and it is the one that
// reflects an install that just finished.
const probed = detection ?? status.detection;
const ready = isUsable(probed);
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
// apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser.
const needsBrowser =
probed !== null &&
probed.chrome_channel === null &&
(probed.browsers.length === 0 || revisionSkew(probed));
const needsSetup = probed !== null && (!ready || needsBrowser);
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
@@ -143,22 +377,80 @@ export default function BrowserTab({ project, active }: Props) {
</span> </span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{live && ( {progress && (
<span
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] min-w-0"
aria-live="polite"
>
<StatusIndicator tone="busy" label="" />
<span className="truncate">{progress}</span>
</span>
)}
{live && poppedOut === true && (
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
Keep on top
{/* The accessible name matches the visible text, as everywhere else
a Toggle is used a `<label>` around it would be inert anyway,
since a Toggle renders a button. */}
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
</span>
)}
{live && poppedOut === true && (
<span
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
>
Match window
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
</span>
)}
{live && poppedOut === false && (
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}> <Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
Reload Reload
</Button> </Button>
)} )}
{live && (
<Button size="md" onClick={() => setAskPage(true)}>
Open a page
</Button>
)}
{live && poppedOut !== null && (
<Button size="md" onClick={poppedOut ? popIn : popOut}>
{poppedOut ? "Put back in tab" : "Open in own window"}
</Button>
)}
<Button <Button
size="md" size="md"
variant={live ? "secondary" : "primary"} variant={live ? "secondary" : "primary"}
disabled={busy} disabled={busy || job !== null}
onClick={() => toggle(!status.enabled || status.state !== "running")} onClick={() => toggle(!status.enabled || status.state !== "running")}
> >
{busy ? "Working…" : live ? "Stop" : "Start browser view"} {busy ? "Working…" : live ? "Stop" : "Start browser view"}
</Button> </Button>
</div> </div>
{live ? ( {live && poppedOut === true ? (
// The iframe is unmounted while the window is up, on purpose. Two
// viewers on one browser both work, but both also *drive* it — two
// cursors taking over the same page is not a feature.
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
<div className="max-w-[28rem] text-center">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
This view is in its own window.
</h2>
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
Move it to another screen, or keep it on top, and watch the browser while
you work here. The view keeps running either way closing the window
brings it back into this tab.
</p>
<div className="mt-3 flex items-center justify-center gap-2">
<Button size="md" variant="primary" onClick={popIn}>
Put back in tab
</Button>
</div>
</div>
</div>
) : live && poppedOut === false ? (
<iframe <iframe
key={reloadKey} key={reloadKey}
// Loopback only, and the URL carries the one-time session token the // Loopback only, and the URL carries the one-time session token the
@@ -167,10 +459,30 @@ export default function BrowserTab({ project, active }: Props) {
title={`Playwright browser view for ${project.name}`} title={`Playwright browser view for ${project.name}`}
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]" className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
/> />
) : live ? (
// Live, but the window's state hasn't come back yet. An instant, and
// deliberately empty: guessing "not popped out" here is what would
// flash a second viewer onto the browser.
<div className="flex-1 min-h-0" />
) : ( ) : (
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
{status.state === "unavailable" ? ( {/* Setup stays on screen while an install is running and after it
<Unavailable status={status} /> finishes, so its output and caveats don't vanish at the moment
they become readable. */}
{needsSetup ||
status.state === "unavailable" ||
job !== null ||
outcome !== null ||
setupError !== null ? (
<Setup
detection={probed}
message={status.state === "unavailable" ? status.message : null}
job={job}
progress={job ? progress : undefined}
outcome={outcome}
error={setupError}
onInstall={install}
/>
) : error ? ( ) : error ? (
<Explainer title="The browser view didnt start." tone="error"> <Explainer title="The browser view didnt start." tone="error">
<span className="font-mono text-xs break-words">{error}</span> <span className="font-mono text-xs break-words">{error}</span>
@@ -186,29 +498,275 @@ export default function BrowserTab({ project, active }: Props) {
)} )}
</div> </div>
)} )}
{askPage && (
<OpenPageDialog
busy={openingPage}
onOpen={openPage}
onClose={() => setAskPage(false)}
/>
)}
</div> </div>
); );
} }
/** The container can't serve a view — say exactly what is missing. */ /** Mirrors Rust `PlaywrightDetection::is_usable`. */
function Unavailable({ status }: { status: BrowserViewStatus }) { function isUsable(d: PlaywrightDetection | null): boolean {
const d = status.detection; return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
* Browsers are installed, but not the revision one of the two Playwright copies
* would launch so the cache looks full and launches fail. A probe that didn't
* answer leaves the executable null, and "unknown" must not read as "broken".
*/
function revisionSkew(d: PlaywrightDetection | null): boolean {
if (!d || d.browsers.length === 0) return false;
// `!= null`, not `!== null`: a probe from a container that predates these
// fields omits them entirely, and `undefined` is "didn't answer" — which must
// never render as "your browsers are wrong".
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
const scriptsBroken =
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
return viewerBroken || scriptsBroken;
}
/**
* The skew sentence, naming both halves.
*
* "Install a browser" over a cache that visibly already holds one reads as
* nonsense, so the copy has to say which copy of Playwright wants what.
*/
function skewText(d: PlaywrightDetection | null): string {
if (!d) return "";
const scriptsBroken =
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
const [version, wanted] = scriptsBroken
? [d.script_playwright_version, d.script_chromium_executable]
: [d.playwright_version, d.chromium_executable];
return ( return (
<div className="p-4 max-w-[46rem] space-y-3"> `This container has ${d.browsers.join(", ")}, but ` +
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]"> `${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
This container cant serve a browser view yet `${version ?? "?"} — launches ${wanted ?? "?"}, which isnt there. ` +
</h2> (scriptsBroken
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed"> ? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
{status.message} : "Install Chromium below: it runs that builds own installer, so it fetches exactly the revision that is missing.")
</p> );
{d && ( }
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]">
<Detail label="Node.js" value={d.node_version} /> /** What the container is short of, as a list rather than as prose. */
<Detail label="Playwright" value={d.playwright_version} /> function missingParts(d: PlaywrightDetection | null): string[] {
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} /> if (!d) return [];
<Detail label="@playwright/cli" value={d.cli_version} /> const out: string[] = [];
{d.searched.length > 0 && ( if (!d.node_version) out.push("Node.js");
<Detail label="Searched" value={d.searched.join(", ")} /> if (!d.playwright_version) out.push("playwright");
else if (!d.has_bind) out.push("a newer playwright — this build has no browser.bind()");
if (!d.cli_entry) out.push("@playwright/cli");
return out;
}
/**
* Setup, as one action per line, each saying what it costs before it is
* pressed.
*
* The old pane printed npm commands here and left the rest to the user. The
* result, verified with a real one: an `@playwright/mcp` install that could
* never satisfy this pane, a global install that hit EACCES, a Chromium that
* downloaded and then would not start because the image shipped none of its
* shared libraries, and a long tail of commands after that. Current base images
* bake those libraries in, so that last one is fixed at the source but a
* project keeps its original base image until it is migrated, so the install
* action still handles a container that lacks them.
*/
function Setup({
detection,
message,
job,
progress,
outcome,
error,
onInstall,
}: {
detection: PlaywrightDetection | null;
message: string | null;
job: SetupJob;
progress?: string;
outcome: BrowserSetupOutcome | null;
error: string | null;
onInstall: (which: Exclude<SetupJob, null>) => void;
}) {
const busy = job !== null;
const havePackages = isUsable(detection);
const missing = missingParts(detection);
const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null;
const noBrowser = browsers.length === 0 && chrome === null;
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
// because the fix is the same button but the sentence must not be "install a
// browser" over a cache that visibly has one.
const skew = revisionSkew(detection) && chrome === null;
return (
<div className="p-4 max-w-[46rem] space-y-4">
<div>
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
{!havePackages
? "This container cant serve a browser view yet"
: skew
? "The installed browser isnt the one Playwright launches"
: noBrowser
? "Playwright is ready — but theres no browser to drive yet"
: "This container is set up"}
</h2>
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
{message ??
(missing.length > 0
? `Missing: ${missing.join(", ")}.`
: skew
? skewText(detection)
: noBrowser
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
: "Start the view from the button above once Claude has a browser open.")}
</p>
</div>
<Step
title="1. Playwright and the viewer UI"
detail={
<>
Installs <Code>playwright</Code> and <Code>@playwright/cli</Code> into{" "}
<Code>/workspace/node_modules</Code> inside the container. That directory is
container storage your project folders are mounted one level down, so
nothing of yours is touched and no <Code>sudo</Code> is involved. Small
download; browsers come next.
</>
}
done={havePackages}
doneLabel={`Installed — playwright ${detection?.playwright_version ?? ""}, @playwright/cli ${detection?.cli_version ?? ""}`}
action={
<Button
size="md"
variant={havePackages ? "secondary" : "primary"}
disabled={busy}
onClick={() => onInstall("packages")}
>
{job === "packages" ? "Installing…" : havePackages ? "Reinstall" : "Set up Playwright"}
</Button>
}
/>
<Step
title="2. A browser to drive"
detail={
<>
Both check the system libraries a browser links against first. Current base
images ship them, so that step is normally skipped; a container built from an
older image gets them installed with apt, which is the difference between a
browser that downloads successfully and one that also starts. Both end by
actually launching the browser to prove it works. Browsers land in{" "}
<Code>~/.cache/ms-playwright</Code>, which is on the home volume, so they
survive container recreation and are only lost on a project Reset.
</>
}
done={browsers.length > 0 || chrome !== null}
doneLabel={[
browsers.length > 0 ? browsers.join(", ") : null,
chrome ? `Chrome channel (${chrome})` : null,
]
.filter(Boolean)
.join(" · ")}
action={
<div className="flex flex-col gap-2 items-end">
<Button
size="md"
variant={browsers.length > 0 || !havePackages ? "secondary" : "primary"}
disabled={busy || !havePackages}
onClick={() => onInstall("chromium")}
>
{job === "chromium" ? "Installing…" : "Install Chromium"}
</Button>
<Button
size="md"
disabled={busy || !havePackages}
onClick={() => onInstall("chrome")}
>
{job === "chrome" ? "Installing…" : "Install Chrome channel"}
</Button>
</div>
}
>
<ul className="mt-2 space-y-1 text-xs text-[var(--text-secondary)] leading-relaxed">
<li>
<strong className="text-[var(--text-primary)]">Chromium</strong> Playwrights
own build, used by <Code>chromium.launch()</Code> with no channel. Several
hundred MB.
</li>
<li>
<strong className="text-[var(--text-primary)]">Chrome channel</strong> Google
Chrome from apt, which is what <Code>@playwright/mcp</Code> asks for. Install
this one if Claude drives the browser through the MCP plugin. Roughly 150 MB.
</li>
</ul>
</Step>
{busy && (
<p
className="text-xs font-mono text-[var(--text-secondary)] break-all"
aria-live="polite"
>
{progress ?? "Working…"}
</p>
)}
{error && (
<div className="text-xs text-[var(--error)]">
<p className="font-semibold">That didnt work.</p>
<pre className="mt-1 whitespace-pre-wrap font-mono break-words text-[var(--text-secondary)]">
{error}
</pre>
</div>
)}
{outcome?.warning && (
<div className="text-xs text-[var(--text-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
<p className="font-semibold">Worth knowing</p>
<p className="mt-1 whitespace-pre-wrap text-[var(--text-secondary)] leading-relaxed">
{outcome.warning}
</p>
</div>
)}
{outcome?.log && (
<AccordionSection
id="browser-view-install-log"
title="Install output"
defaultOpen={false}
>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-64 overflow-y-auto">
{outcome.log}
</pre>
</AccordionSection>
)}
{detection && (
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-3 border-t border-[var(--border-color)]">
<Detail label="Node.js" value={detection.node_version} />
<Detail label="Playwright" value={detection.playwright_version} />
<Detail label="Resolved from" value={detection.playwright_path} />
<Detail
label="browser.bind()"
value={detection.has_bind ? "available" : "not in this build"}
/>
<Detail label="@playwright/cli" value={detection.cli_version} />
<Detail
label="Browsers"
value={browsers.length > 0 ? browsers.join(", ") : null}
/>
<Detail label="Chrome channel" value={chrome} />
{detection.searched.length > 0 && (
<Detail label="Searched" value={detection.searched.join(", ")} />
)} )}
</dl> </dl>
)} )}
@@ -216,6 +774,44 @@ function Unavailable({ status }: { status: BrowserViewStatus }) {
); );
} }
/** One numbered setup step: what it does, whether it is done, and its button. */
function Step({
title,
detail,
done,
doneLabel,
action,
children,
}: {
title: string;
detail: React.ReactNode;
done: boolean;
doneLabel?: string;
action: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<div className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h3>
<StatusIndicator tone={done ? "ok" : "off"} label={done ? "Installed" : "Not installed"} />
</div>
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-relaxed">{detail}</p>
{done && doneLabel && (
<p className="mt-1 text-xs font-mono text-[var(--text-secondary)] break-all">
{doneLabel}
</p>
)}
{children}
</div>
<div className="flex-shrink-0">{action}</div>
</div>
</div>
);
}
function Detail({ label, value }: { label: string; value: string | null }) { function Detail({ label, value }: { label: string; value: string | null }) {
return ( return (
<> <>
@@ -0,0 +1,148 @@
import { useState } from "react";
import Modal from "../../ui/Modal";
import Button from "../../ui/Button";
/**
* Viewport presets. These are the *page's* resolution, not the window's the
* pane is a screencast, so a bigger window shows the same pixels drawn larger
* while this is what actually reflows the layout.
*/
const PRESETS: { label: string; width: number; height: number }[] = [
{ label: "1280 × 720", width: 1280, height: 720 },
{ label: "1920 × 1080", width: 1920, height: 1080 },
{ label: "1440 × 900", width: 1440, height: 900 },
{ label: "390 × 844 (phone)", width: 390, height: 844 },
];
interface Props {
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
initialUrl?: string;
initialWidth?: number;
initialHeight?: number;
busy?: boolean;
onOpen: (url: string, width: number, height: number) => void;
onClose: () => void;
}
/**
* Ask for a URL and a viewport, then open it in the container's browser.
*
* Deliberately modal and short-lived the convention for a task with one
* question and one button. The URL is not opened here; the caller runs the
* command so failures land in its toast.
*/
export default function OpenPageDialog({
initialUrl = "",
initialWidth = 1280,
initialHeight = 720,
busy = false,
onOpen,
onClose,
}: Props) {
const [url, setUrl] = useState(initialUrl);
const [width, setWidth] = useState(initialWidth);
const [height, setHeight] = useState(initialHeight);
const trimmed = url.trim();
// Mirrors the backend's allow-list, so the error arrives before the click
// rather than after a round trip.
const valid = /^https?:\/\/\S+$/i.test(trimmed);
return (
<Modal
title="Open a page in the container's browser"
onClose={onClose}
footer={
<>
<Button size="md" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={!valid || busy}
onClick={() => onOpen(trimmed, width, height)}
>
{busy ? "Opening…" : "Open page"}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
Launches a browser <em>inside</em> this container and publishes it to the
Browser tab. Use it for a sign-in page the callback listener is in the
container too, so the login completes without involving your host browser
or for a dev server on container loopback.
</p>
<label className="block">
<span className="text-xs text-[var(--text-secondary)]">URL</span>
<input
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
}}
placeholder="http://localhost:5173"
spellCheck={false}
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
/>
{trimmed !== "" && !valid && (
<span className="mt-1 block text-xs text-[var(--error)]">
Only http:// and https:// URLs can be opened.
</span>
)}
</label>
<div>
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{PRESETS.map((p) => {
const active = p.width === width && p.height === height;
return (
<button
key={p.label}
type="button"
aria-pressed={active}
onClick={() => {
setWidth(p.width);
setHeight(p.height);
}}
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
active
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{p.label}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
aria-label="Viewport width"
value={width}
min={200}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
<input
type="number"
aria-label="Viewport height"
value={height}
min={200}
onChange={(e) => setHeight(Number(e.target.value))}
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
</div>
</div>
</div>
</Modal>
);
}
@@ -43,6 +43,16 @@ export default function ProjectHome({ projectId, active }: Props) {
const { projects, remove } = useProjects(); const { projects, remove } = useProjects();
const project = projects.find((p) => p.id === projectId); const project = projects.find((p) => p.id === projectId);
const [tab, setTab] = useState<ProjectHomeTabId>("overview"); const [tab, setTab] = useState<ProjectHomeTabId>("overview");
// Somewhere else asked for this project on a particular sub-tab — currently
// "I opened a page in the container's browser, show me it". Consumed once, so
// it cannot fight the user's own clicking afterwards.
const pendingHomeTab = useAppState((s) => s.pendingHomeTab);
useEffect(() => {
if (pendingHomeTab?.projectId !== projectId) return;
setTab(pendingHomeTab.tab as ProjectHomeTabId);
useAppState.getState().clearPendingHomeTab();
}, [pendingHomeTab, projectId]);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
const [confirmReset, setConfirmReset] = useState(false); const [confirmReset, setConfirmReset] = useState(false);
const [showMigration, setShowMigration] = useState(false); const [showMigration, setShowMigration] = useState(false);
@@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import type { Project } from "../../../../lib/types"; import type { Project } from "../../../../lib/types";
import Button from "../../../ui/Button"; import Button from "../../../ui/Button";
import Field, { ConfigGroup, inputClass } from "../../../ui/Field"; import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
import CaCertPathInput from "../../../settings/CaCertPathInput";
import EnvVarsEditor from "../../EnvVarsEditor"; import EnvVarsEditor from "../../EnvVarsEditor";
import PortMappingsEditor from "../../PortMappingsEditor"; import PortMappingsEditor from "../../PortMappingsEditor";
@@ -20,12 +21,14 @@ export default function AccessSection({
disabledReason, disabledReason,
}: Props) { }: Props) {
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? ""); const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
const [gitToken, setGitToken] = useState(project.git_token ?? ""); const [gitToken, setGitToken] = useState(project.git_token ?? "");
useEffect(() => { useEffect(() => {
setSshKeyPath(project.ssh_key_path ?? ""); setSshKeyPath(project.ssh_key_path ?? "");
setCaCertPath(project.ca_cert_path ?? "");
setGitName(project.git_user_name ?? ""); setGitName(project.git_user_name ?? "");
setGitEmail(project.git_user_email ?? ""); setGitEmail(project.git_user_email ?? "");
setGitToken(project.git_token ?? ""); setGitToken(project.git_token ?? "");
@@ -114,6 +117,24 @@ export default function AccessSection({
)} )}
</Field> </Field>
<Field
label="Corporate CA certificate"
hint="Overrides the global certificate for this project only. A certificate file, or a folder of them, trusted inside the container by curl, git, npm, pip, Chromium and Claude Code."
>
{(id) => (
<CaCertPathInput
id={id}
value={caCertPath}
onChange={setCaCertPath}
onCommit={(value) => save({ ca_cert_path: value.trim() || null })}
disabled={disabled}
placeholder="/etc/ssl/certs/corp-root.pem"
emptyHint="Using the global certificate from Settings → Certificates."
inputClassName={`${inputClass} min-w-0`}
/>
)}
</Field>
<div className="pt-2 border-t border-[var(--border-color)]"> <div className="pt-2 border-t border-[var(--border-color)]">
<span className="block text-[13px] font-medium text-[var(--text-primary)]"> <span className="block text-[13px] font-medium text-[var(--text-primary)]">
Environment variables Environment variables
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import CaCertPathInput from "./CaCertPathInput";
import type { CaCertInfo } from "../../lib/types";
const inspectCaCertPath = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
inspectCaCertPath: (path: string) => inspectCaCertPath(path),
}));
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (opts: unknown) => openDialog(opts),
}));
const info = (over: Partial<CaCertInfo> = {}): CaCertInfo => ({
exists: true,
is_directory: false,
cert_count: 1,
installed_names: ["corp-root.crt"],
error: null,
...over,
});
function renderInput(value = "", over: Partial<Parameters<typeof CaCertPathInput>[0]> = {}) {
const onChange = vi.fn();
const onCommit = vi.fn();
const utils = render(
<CaCertPathInput
value={value}
onChange={onChange}
onCommit={onCommit}
inputClassName="input"
{...over}
/>,
);
return { onChange, onCommit, ...utils };
}
describe("CaCertPathInput", () => {
beforeEach(() => {
vi.clearAllMocks();
inspectCaCertPath.mockResolvedValue(info());
});
it("does not inspect anything while the path is empty", async () => {
renderInput("");
await new Promise((r) => setTimeout(r, 350));
expect(inspectCaCertPath).not.toHaveBeenCalled();
});
it("shows the empty hint instead of a status when unset", () => {
renderInput("", { emptyHint: "Using the global certificate." });
expect(screen.getByText("Using the global certificate.")).toBeTruthy();
});
it("reports the certificate count and the names they are installed as", async () => {
// The rename is the whole point: update-ca-certificates ignores a .pem.
inspectCaCertPath.mockResolvedValue(
info({ cert_count: 2, installed_names: ["corp-root.crt", "corp-intermediate.crt"] }),
);
renderInput("/certs");
await waitFor(() => expect(screen.getByText(/Found 2 certificates/)).toBeTruthy());
expect(screen.getByText(/corp-root\.crt, corp-intermediate\.crt/)).toBeTruthy();
});
it("uses the singular for one certificate", async () => {
renderInput("/certs/corp.pem");
await waitFor(() => expect(screen.getByText(/Found 1 certificate$|Found 1 certificate/)).toBeTruthy());
expect(screen.queryByText(/Found 1 certificates/)).toBeNull();
});
it("surfaces an unusable path inline rather than silently accepting it", async () => {
inspectCaCertPath.mockResolvedValue(
info({ exists: false, cert_count: 0, installed_names: [], error: "path does not exist" }),
);
renderInput("/gone");
await waitFor(() => expect(screen.getByText(/path does not exist/)).toBeTruthy());
});
it("commits on blur", () => {
const { onCommit } = renderInput("/certs");
fireEvent.blur(screen.getByRole("textbox"));
expect(onCommit).toHaveBeenCalledWith("/certs");
});
it("offers both a file and a folder picker, because the setting accepts either", async () => {
openDialog.mockResolvedValue("/picked/corp.pem");
const { onChange, onCommit } = renderInput("");
fireEvent.click(screen.getByText("File…"));
await waitFor(() => expect(onCommit).toHaveBeenCalledWith("/picked/corp.pem"));
expect(openDialog).toHaveBeenCalledWith({ directory: false, multiple: false });
openDialog.mockResolvedValue("/picked/certs");
fireEvent.click(screen.getByText("Folder…"));
await waitFor(() => expect(openDialog).toHaveBeenLastCalledWith({ directory: true, multiple: false }));
expect(onChange).toHaveBeenCalledWith("/picked/certs");
});
it("does not commit when the picker is dismissed", async () => {
openDialog.mockResolvedValue(null);
const { onCommit } = renderInput("");
fireEvent.click(screen.getByText("Folder…"));
await new Promise((r) => setTimeout(r, 0));
expect(onCommit).not.toHaveBeenCalled();
});
it("disables the inputs when the container is running", () => {
renderInput("/certs", { disabled: true });
expect((screen.getByRole("textbox") as HTMLInputElement).disabled).toBe(true);
for (const label of ["File…", "Folder…"]) {
expect((screen.getByText(label) as HTMLButtonElement).disabled).toBe(true);
}
});
});
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import Button from "../ui/Button";
import { inspectCaCertPath } from "../../lib/tauri-commands";
import type { CaCertInfo } from "../../lib/types";
interface Props {
/** Wired to the calling `Field`'s label, where there is one. */
id?: string;
value: string;
onChange: (value: string) => void;
/** Persist the value — called on blur and immediately after a Browse. */
onCommit: (value: string) => void;
disabled?: boolean;
placeholder?: string;
/** Shown in place of the status line while the field is empty. */
emptyHint?: string;
/** Tailwind classes for the text input, so each caller keeps its local
* convention (the host settings panel and the project Config tab do not
* style their inputs the same way). */
inputClassName: string;
}
/**
* Path field for a corporate CA certificate a single file *or* a directory
* of them shared by the global setting and the per-project override.
*
* Two Browse buttons rather than one: the platform file dialog cannot offer
* "a file or a folder" in a single call, and which one the user wants is not
* guessable (a lone `corp-root.pem` is as common as a folder of chained certs).
*
* The status line is what makes the feature debuggable. It reports the
* certificate count and, crucially, the `.crt` names each file is installed
* as: `update-ca-certificates` matches `*.crt` case-sensitively and ignores a
* `.pem` in complete silence, so seeing `corp-root.pem → corp-root.crt` is the
* difference between trusting the setting and guessing at it.
*/
export default function CaCertPathInput({
id,
value,
onChange,
onCommit,
disabled = false,
placeholder,
emptyHint,
inputClassName,
}: Props) {
const [info, setInfo] = useState<CaCertInfo | null>(null);
// Guards against a slow inspect for an earlier value landing after a newer
// one and describing the wrong path.
const requestId = useRef(0);
useEffect(() => {
const trimmed = value.trim();
if (!trimmed) {
setInfo(null);
return;
}
const id = ++requestId.current;
const timer = setTimeout(() => {
inspectCaCertPath(trimmed)
.then((result) => {
if (requestId.current === id) setInfo(result);
})
.catch(() => {
if (requestId.current === id) setInfo(null);
});
}, 250);
return () => clearTimeout(timer);
}, [value]);
const browse = async (directory: boolean) => {
const selected = await open({ directory, multiple: false });
if (typeof selected === "string") {
onChange(selected);
onCommit(selected);
}
};
return (
<div className="space-y-1.5">
<div className="flex gap-1.5">
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onCommit(value)}
placeholder={placeholder}
disabled={disabled}
className={inputClassName}
/>
<Button size="md" disabled={disabled} onClick={() => browse(false)}>
File
</Button>
<Button size="md" disabled={disabled} onClick={() => browse(true)}>
Folder
</Button>
</div>
<CaCertStatus value={value} info={info} emptyHint={emptyHint} />
</div>
);
}
function CaCertStatus({
value,
info,
emptyHint,
}: {
value: string;
info: CaCertInfo | null;
emptyHint?: string;
}) {
if (!value.trim()) {
return emptyHint ? (
<p className="text-xs text-[var(--text-secondary)]">{emptyHint}</p>
) : null;
}
if (!info) return null;
if (info.error) {
// Glyph + word, never colour alone.
return (
<p className="text-xs text-[var(--error)]" role="status">
<span aria-hidden="true"> </span>
Problem: {info.error}
</p>
);
}
if (info.cert_count === 0) return null;
return (
<p className="text-xs text-[var(--success)]" role="status">
<span aria-hidden="true"> </span>
Found {info.cert_count} certificate{info.cert_count === 1 ? "" : "s"}
{info.installed_names.length > 0 && (
<span className="text-[var(--text-secondary)]">
{" "}
installed as {info.installed_names.slice(0, 4).join(", ")}
{info.installed_names.length > 4
? ` and ${info.installed_names.length - 4} more`
: ""}
</span>
)}
</p>
);
}
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import CaCertPathInput from "./CaCertPathInput";
const INPUT_CLASS =
"flex-1 min-w-0 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]";
/**
* Global corporate CA certificate setting.
*
* Applies to every project unless one overrides it in Project Home Config
* Access. Changing it recreates each container on its next start the
* certificate is copied into the container's trust store once, at start, so
* there is nowhere else for a change to land.
*/
export default function CertificateSettings() {
const { appSettings, saveSettings } = useSettings();
const [path, setPath] = useState(appSettings?.ca_cert_path ?? "");
useEffect(() => {
setPath(appSettings?.ca_cert_path ?? "");
}, [appSettings?.ca_cert_path]);
const commit = async (value: string) => {
if (!appSettings) return;
const next = value.trim() || null;
if (next === appSettings.ca_cert_path) return;
await saveSettings({ ...appSettings, ca_cert_path: next });
};
return (
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="global-ca-cert-path"
>
Corporate CA Certificate
</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
A certificate file, or a folder of them, for organisations whose network
inspects TLS. Mounted read-only into every container and trusted by
curl, git, npm, pip, Chromium and Claude Code itself. Per-project
settings override this; changing it recreates containers on next start.
</p>
<CaCertPathInput
id="global-ca-cert-path"
value={path}
onChange={setPath}
onCommit={commit}
placeholder="/etc/ssl/certs/corp-root.pem"
emptyHint="Not set — containers trust only the public CAs shipped with the image."
inputClassName={INPUT_CLASS}
/>
</div>
);
}
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
}), }),
})); }));
/** Every event the hook subscribes to, so the unmount test counts the right
* number of teardowns instead of a magic number that drifts. */
const EVENT_NAMES = [
"claude-token-progress",
"claude-token-output",
"claude-token-link",
"claude-token-code-rejected",
];
/** The sign-in URL at its real length (346 characters, measured against
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
* transcript ever contains. */
const FULL_URL =
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
const TRUNCATED_URL = FULL_URL.slice(0, 80);
function emitOutput(chunk: string, projectId = "p1") { function emitOutput(chunk: string, projectId = "p1") {
act(() => { act(() => {
handlers.get("claude-token-output")?.({ handlers.get("claude-token-output")?.({
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
}); });
} }
function emitLink(url: string, projectId = "p1") {
act(() => {
handlers.get("claude-token-link")?.({
payload: { project_id: projectId, url },
});
});
}
function emitCodeRejected(message: string, attemptsRemaining: number) {
act(() => {
handlers.get("claude-token-code-rejected")?.({
payload: {
project_id: "p1",
message,
attempts_remaining: attemptsRemaining,
},
});
});
}
function renderModal( function renderModal(
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {}, overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
) { ) {
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
const { unmount } = renderModal(); const { unmount } = renderModal();
await flowStarted(); await flowStarted();
unmount(); unmount();
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2)); await waitFor(() =>
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
);
});
// ── The hyperlink target, not the wrapped display text ────────────────
//
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
// the terminal width, so the transcript holds five 80-character pieces of a
// 346-character URL. The backend lifts the whole thing out of the hyperlink
// parameter and sends it on `claude-token-link`.
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
renderModal();
await flowStarted();
// What the transcript holds: the first slice only.
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
// What the hyperlink parameter holds: all of it.
emitLink(FULL_URL);
const link = await screen.findByRole("link", { name: FULL_URL });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
});
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
renderModal();
await flowStarted();
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(openUrl).not.toHaveBeenCalled();
});
it("ignores a hyperlink belonging to a different project", async () => {
renderModal();
await flowStarted();
emitLink(FULL_URL, "p2");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
// ── A refused code is recoverable, not a hang ─────────────────────────
it("reports a rejected code and lets another one be submitted", async () => {
renderModal();
await flowStarted();
const input = screen.getByLabelText("Authentication code");
fireEvent.change(input, { target: { value: "truncated" } });
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
);
// Before the rejection arrives the UI claims the sign-in is completing.
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
emitCodeRejected(
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
2,
);
// Reported, not waited out — and the flow is still live.
await screen.findByText(/That code was rejected/);
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
// A second code goes through without restarting the whole flow.
fireEvent.change(input, { target: { value: "the-whole-code" } });
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
);
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
});
it("ends with a reported failure when the retries run out", async () => {
acquireClaudeToken.mockRejectedValue(
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
);
renderModal();
const banner = await screen.findByTestId("claude-auth-error");
expect(banner).toHaveTextContent(/rejected the code 3 times/);
}); });
}); });
@@ -27,6 +27,9 @@ interface Props {
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = { const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
waiting: { tone: "busy", label: "Waiting for sign-in" }, waiting: { tone: "busy", label: "Waiting for sign-in" },
finishing: { tone: "busy", label: "Finishing sign-in" }, finishing: { tone: "busy", label: "Finishing sign-in" },
// The CLI refused a code and is back at its prompt. Distinct from "failed":
// the flow is still live and another code will be accepted.
rejected: { tone: "error", label: "Code rejected — try again" },
succeeded: { tone: "ok", label: "Token stored" }, succeeded: { tone: "ok", label: "Token stored" },
failed: { tone: "error", label: "Authentication failed" }, failed: { tone: "error", label: "Authentication failed" },
}; };
@@ -88,7 +91,9 @@ export default function ClaudeAuthModal({
? PHASE_STATUS.failed ? PHASE_STATUS.failed
: flow.codeSubmitted : flow.codeSubmitted
? PHASE_STATUS.finishing ? PHASE_STATUS.finishing
: PHASE_STATUS.waiting; : flow.codeRejections > 0
? PHASE_STATUS.rejected
: PHASE_STATUS.waiting;
// Split for display only. `flow.signInUrl` has already passed the host // Split for display only. `flow.signInUrl` has already passed the host
// allowlist; this decides which half of it an ellipsis is allowed to eat. // allowlist; this decides which half of it an ellipsis is allowed to eat.
@@ -18,6 +18,7 @@ import Toggle from "../ui/Toggle";
import WebTerminalSettings from "./WebTerminalSettings"; import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings"; import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings"; import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
export default function SettingsPanel() { export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings(); const { appSettings, saveSettings } = useSettings();
@@ -172,6 +173,10 @@ export default function SettingsPanel() {
<DockerSettings /> <DockerSettings />
</AccordionSection> </AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}> <AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
{/* Default SSH Key Directory */} {/* Default SSH Key Directory */}
<div> <div>
+57 -3
View File
@@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands"; import {
awsSsoRefresh,
openPageInContainerBrowser,
uploadHostFileToTerminal,
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
import { import {
@@ -370,8 +374,12 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal // Handle backend output -> terminal
let aborted = false; let aborted = false;
const detector = new UrlDetector((url) => // The width is read per scan, not captured: only a break the terminal
promptUrl(url, "Long URL detected"), // itself inserted may be deleted, and where that is moves with every
// resize.
const detector = new UrlDetector(
(url) => promptUrl(url, "Long URL detected"),
() => termRef.current?.cols ?? 0,
); );
detectorRef.current = detector; detectorRef.current = detector;
@@ -529,6 +537,51 @@ export default function TerminalView({ sessionId, active }: Props) {
openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt]); }, [urlPrompt]);
/**
* Open the prompted URL in the container's own browser instead of the host's.
*
* For a sign-in this is the shorter path: the callback listener the tool is
* waiting on is inside the container, so a container-side browser closes the
* loop with nothing crossing to the host. The page is published to the
* project's Browser tab, which is where the user completes it by hand.
*/
const handleOpenUrlInContainer = useCallback(() => {
if (!urlPrompt) return;
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
if (!projectId) return;
// Land on the pane that will show it, before the work starts: opening takes
// several seconds, and the progress line lives there.
useAppState.getState().openProjectHomeTab(projectId, "browser");
// A sign-in page is the one case where the *window* size matters least and
// the layout matters most, so it gets the ordinary desktop viewport.
// `true`: from a terminal there is no Browser pane on screen, so the page
// needs a window of its own or it opens somewhere the user isn't looking.
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
.then((result) => {
const push = useAppState.getState().pushToast;
if (result.error) {
push({ kind: "error", message: "The page didnt open", detail: result.error });
} else {
push({
kind: "success",
message: "Opened in the containers browser",
});
}
})
.catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open it in the containers browser",
detail: String(e),
}),
);
}, [urlPrompt, projectId]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
const term = termRef.current; const term = termRef.current;
if (term) { if (term) {
@@ -606,6 +659,7 @@ export default function TerminalView({ sessionId, active }: Props) {
url={urlPrompt.url} url={urlPrompt.url}
label={urlPrompt.label} label={urlPrompt.label}
onOpen={handleOpenUrl} onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
onDismiss={() => setUrlPrompt(null)} onDismiss={() => setUrlPrompt(null)}
/> />
)} )}
+28
View File
@@ -6,6 +6,9 @@ interface Props {
/** Heading above the URL. Says why the toast appeared. */ /** Heading above the URL. Says why the toast appeared. */
label?: string; label?: string;
onOpen: () => void; onOpen: () => void;
/** Open it in the container's own browser instead of the host's. Omitted when
* the project has no browser to open it in. */
onOpenInContainer?: () => void;
onDismiss: () => void; onDismiss: () => void;
} }
@@ -30,6 +33,7 @@ export default function UrlToast({
url, url,
label = "Long URL detected", label = "Long URL detected",
onOpen, onOpen,
onOpenInContainer,
onDismiss, onDismiss,
}: Props) { }: Props) {
const origin = urlOrigin(url); const origin = urlOrigin(url);
@@ -131,6 +135,30 @@ export default function UrlToast({
Open Open
</button> </button>
{onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback
// on the container's own loopback, which is where the tool waiting for
// it is listening — no host round trip, no auth bridge.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
In container
</button>
)}
<button <button
onClick={onDismiss} onClick={onDismiss}
style={{ style={{
+63 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth"; import {
authErrorMessage,
extractSignInUrl,
pickSignInUrl,
} from "./useClaudeAuth";
describe("extractSignInUrl", () => { describe("extractSignInUrl", () => {
it("finds the authorize URL in realistic setup-token output", () => { it("finds the authorize URL in realistic setup-token output", () => {
@@ -79,6 +83,64 @@ describe("extractSignInUrl", () => {
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1"; const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first); expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
}); });
// ── Why the scraper is only the fallback ─────────────────────────────────
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
// *visible* text of it to the terminal width, so the transcript holds five
// 80-character pieces of a 346-character URL. Each piece is a valid,
// Anthropic-hosted, oauth-looking URL — and none of them authorises
// anything.
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
const slices = [
FULL_URL.slice(0, 80),
FULL_URL.slice(80, 160),
FULL_URL.slice(160, 240),
FULL_URL.slice(240, 320),
FULL_URL.slice(320),
];
const scraped = extractSignInUrl(slices.join("\n"));
// Documenting the limit, not endorsing it: the pieces share no prefix, so
// the "extends the current pick" rule cannot join them, and guessing at
// line joins on an untrusted stream is not on the table.
expect(scraped).toBe(slices[0]);
expect(scraped).not.toBe(FULL_URL);
// The hyperlink parameter carries the whole thing, and that is what the
// hook prefers.
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
});
});
/** The real sign-in URL, at its measured length (346 characters, Claude Code
* 2.1.226). */
const FULL_URL =
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
describe("pickSignInUrl", () => {
it("keeps a 346-character authorize URL intact", () => {
expect(FULL_URL).toHaveLength(346);
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
});
it("applies the same host allowlist to a hyperlink target", () => {
// An OSC 8 parameter is container output like anything else, and it is
// never displayed — so it is the *easier* place to hide a hostile host.
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
expect(
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
).toBeNull();
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
expect(pickSignInUrl([])).toBeNull();
});
it("does not let a later hyperlink displace the one already shown", () => {
const real = `${FULL_URL}`;
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
expect(pickSignInUrl([real, spoof])).toBe(real);
expect(pickSignInUrl([spoof, real])).toBe(real);
});
}); });
describe("authErrorMessage", () => { describe("authErrorMessage", () => {
+85 -15
View File
@@ -3,6 +3,8 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay"; import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
import type { import type {
ClaudeTokenCodeRejectedEvent,
ClaudeTokenLinkEvent,
ClaudeTokenOutputEvent, ClaudeTokenOutputEvent,
ClaudeTokenProgressEvent, ClaudeTokenProgressEvent,
} from "../lib/types"; } from "../lib/types";
@@ -19,10 +21,17 @@ import type {
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */ /** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
const PROGRESS_EVENT = "claude-token-progress"; const PROGRESS_EVENT = "claude-token-progress";
const OUTPUT_EVENT = "claude-token-output"; const OUTPUT_EVENT = "claude-token-output";
const LINK_EVENT = "claude-token-link";
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
/** Bound on the retained transcript. The tail is the interesting part. */ /** Bound on the retained transcript. The tail is the interesting part. */
const MAX_OUTPUT = 64 * 1024; const MAX_OUTPUT = 64 * 1024;
/** Bound on retained sign-in candidates. The backend already deduplicates
* consecutive repeats; this stops a container that prints a fresh hyperlink
* every frame from growing state without limit. */
const MAX_LINKS = 16;
/** /**
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this * Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
* backend writes its errors as complete, actionable sentences ("The container * backend writes its errors as complete, actionable sentences ("The container
@@ -38,13 +47,13 @@ export function authErrorMessage(e: unknown, fallback: string): string {
} }
/** /**
* Pick the sign-in URL out of `claude setup-token`'s transcript. * Choose one sign-in URL from a list of candidates.
* *
* **The transcript is container output, so every candidate here is * **Every candidate is container output, so all of them are
* attacker-controlled if the sandboxed agent misbehaves.** It is then rendered * attacker-controlled if the sandboxed agent misbehaves.** The winner is
* under a heading that says "Sign in with Anthropic" and handed to the host * rendered under a heading that says "Sign in with Anthropic" and handed to the
* browser, which makes this the highest-value URL in the app to spoof: a user * host browser, which makes this the highest-value URL in the app to spoof: a
* who follows it types their real Anthropic credentials into whatever it * user who follows it types their real Anthropic credentials into whatever it
* resolves to. Three rules follow, and none of them are optional: * resolves to. Three rules follow, and none of them are optional:
* *
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a * - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
@@ -60,14 +69,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
* the complete one and it cannot swap the origin, because a longer string * the complete one and it cannot swap the origin, because a longer string
* with the same prefix has the same host. * with the same prefix has the same host.
*/ */
export function extractSignInUrl(text: string): string | null { export function pickSignInUrl(candidates: readonly string[]): string | null {
// eslint-disable-next-line no-control-regex const cleaned = candidates
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
if (!matches) return null;
const cleaned = matches
// Trailing punctuation belongs to the prose, not the URL.
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS })) .map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
.filter((url): url is string => url !== null); .filter((url): url is string => url !== null);
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
return best; return best;
} }
/**
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
*
* **This is the fallback, not the primary route.** The CLI emits the URL as an
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
* terminal width measured at 80 columns, a 346-character URL arrives as five
* 80-character pieces on five lines. Nothing scraping the visible text can put
* those back together: the pieces share no prefix, so the "extends the current
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
* untrusted stream is exactly the sort of thing the rules above exist to
* forbid. What comes out is the first 80 characters a URL that parses, that
* points at claude.com, and that cannot authorise anything.
*
* So the backend lifts the whole URL out of the hyperlink parameter and sends
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
* This remains for CLI versions that print a bare URL with no hyperlink at all,
* where a URL narrow enough not to wrap is recovered correctly.
*/
export function extractSignInUrl(text: string): string | null {
// eslint-disable-next-line no-control-regex
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
if (!matches) return null;
// Trailing punctuation belongs to the prose, not the URL.
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Token presence // Token presence
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
submitting: boolean; submitting: boolean;
codeSubmitted: boolean; codeSubmitted: boolean;
submitError: string | null; submitError: string | null;
/**
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
* still alive and waiting for another one a recoverable state, not the end
* of the flow.
*/
codeRejections: number;
submitCode: (code: string) => Promise<boolean>; submitCode: (code: string) => Promise<boolean>;
} }
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [codeSubmitted, setCodeSubmitted] = useState(false); const [codeSubmitted, setCodeSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [codeRejections, setCodeRejections] = useState(0);
// Candidates from `claude-token-link`, in arrival order. Kept as a list
// rather than a single value so `pickSignInUrl` applies the same first-wins
// rule here as it does to the scraped transcript — the CLI reprints the same
// hyperlink after every retry, and a *different* one arriving later must not
// be able to displace the one the user was already shown.
const [links, setLinks] = useState<string[]>([]);
// Held in a ref so a fresh callback identity cannot restart the flow. // Held in a ref so a fresh callback identity cannot restart the flow.
const succeededRef = useRef(onSucceeded); const succeededRef = useRef(onSucceeded);
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
: next; : next;
}); });
}); });
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
if (payload.project_id !== projectId) return;
setLinks((prev) =>
prev.includes(payload.url) || prev.length >= MAX_LINKS
? prev
: [...prev, payload.url],
);
});
await register<ClaudeTokenCodeRejectedEvent>(
CODE_REJECTED_EVENT,
(payload) => {
if (payload.project_id !== projectId) return;
// The CLI is alive and back at its prompt, so this is a correction
// the user can act on — not a failure. Re-open the input and say
// why, rather than leaving "Finishing sign-in" on screen forever.
setCodeRejections((n) => n + 1);
setCodeSubmitted(false);
setSubmitError(payload.message);
},
);
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
setPhase("failed"); setPhase("failed");
@@ -261,7 +324,13 @@ export function useClaudeTokenAcquisition(
} }
}, []); }, []);
const signInUrl = useMemo(() => extractSignInUrl(output), [output]); // The hyperlink parameter wins whenever there is one: it is the only place
// the CLI emits the URL contiguously. Scraping the visible text is the
// fallback for versions that print a bare URL — see `extractSignInUrl`.
const signInUrl = useMemo(
() => pickSignInUrl(links) ?? extractSignInUrl(output),
[links, output],
);
return { return {
phase, phase,
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
submitting, submitting,
codeSubmitted, codeSubmitted,
submitError, submitError,
codeRejections,
submitCode, submitCode,
}; };
} }
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
import { useAppState, homeTabKey, terminalTabKey } from "../store/appState";
vi.mock("./useTerminal", () => ({
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
}));
const HOME = homeTabKey("p1");
const S1 = terminalTabKey("s1");
const S2 = terminalTabKey("s2");
const order = () => useAppState.getState().tabOrder;
/** Press a chord, from whatever is focused. */
function press(key: string, { shift = false } = {}) {
document.dispatchEvent(
new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: shift, bubbles: true }),
);
}
/** Focus a real element of the given kind, inside `parent` if given. */
function focus(tag: "input" | "textarea", parentClass?: string): HTMLElement {
const el = document.createElement(tag);
if (parentClass) {
const parent = document.createElement("div");
parent.className = parentClass;
parent.appendChild(el);
document.body.appendChild(parent);
} else {
document.body.appendChild(el);
}
el.focus();
return el;
}
beforeEach(() => {
useAppState.setState({ tabOrder: [HOME, S1, S2], activeTabKey: S1, activeSessionId: "s1" });
});
afterEach(() => {
document.body.innerHTML = "";
});
describe("Ctrl+Shift+←/→", () => {
it("moves the active tab along the strip", () => {
renderHook(() => useKeyboardShortcuts());
press("ArrowLeft", { shift: true });
expect(order()).toEqual([S1, HOME, S2]);
press("ArrowRight", { shift: true });
expect(order()).toEqual([HOME, S1, S2]);
});
it("leaves word-wise selection alone in a text field", () => {
// Ctrl+Shift+←/→ already means "extend the selection by a word" in every
// input in the app — the rename field, Config fields, Settings fields.
// Taking it there would break selection *and* silently reorder tabs.
renderHook(() => useKeyboardShortcuts());
focus("input");
press("ArrowLeft", { shift: true });
expect(order()).toEqual([HOME, S1, S2]);
});
it("still moves tabs from the terminal, whose focus lives in a textarea", () => {
// xterm keeps a hidden textarea focused as its input-method shim. It is
// not a field anyone edits word-wise, and the terminal is where these
// shortcuts matter most, so it is not treated as one.
renderHook(() => useKeyboardShortcuts());
focus("textarea", "xterm xterm-helper-textarea-host");
press("ArrowLeft", { shift: true });
expect(order()).toEqual([S1, HOME, S2]);
});
it("does nothing without Shift — that chord is readline's word motion", () => {
renderHook(() => useKeyboardShortcuts());
press("ArrowLeft");
expect(order()).toEqual([HOME, S1, S2]);
});
});
+33
View File
@@ -2,6 +2,22 @@ import { useEffect } from "react";
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState"; import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
import { useTerminal } from "./useTerminal"; import { useTerminal } from "./useTerminal";
/**
* Whether the focus is in something the user is editing text in.
*
* xterm's hidden textarea is deliberately excluded: it is an input-method
* shim, not a field anyone edits word-wise, and the terminal is exactly where
* the tab shortcuts need to keep working.
*/
function inTextField(el: Element | null): boolean {
if (!el || el.closest(".xterm")) return false;
return (
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable === true
);
}
/** /**
* App-level shortcuts. Registered on `document` in the *capture* phase so they * App-level shortcuts. Registered on `document` in the *capture* phase so they
* win over xterm.js, which would otherwise forward them to the shell inside * win over xterm.js, which would otherwise forward them to the shell inside
@@ -11,6 +27,7 @@ import { useTerminal } from "./useTerminal";
* Ctrl+Shift+W close the active tab * Ctrl+Shift+W close the active tab
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous) * Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
* Ctrl+1..9 jump to the nth tab * Ctrl+1..9 jump to the nth tab
* Ctrl+Shift+/ move the active tab along the strip
*/ */
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const { open: openTerminal, close: closeTerminal } = useTerminal(); const { open: openTerminal, close: closeTerminal } = useTerminal();
@@ -51,6 +68,22 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
// dragging a tab does. Shift is what keeps it clear of the terminal:
// Ctrl+←/→ is readline's word-wise cursor motion.
//
// In a text field this chord already means "extend the selection by a
// word", which is not ours to take: swallowing it would make word-wise
// selection impossible in every input in the app *and* silently reorder
// the strip each time someone tried it.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
if (!state.activeTabKey || inTextField(document.activeElement)) return;
e.preventDefault();
e.stopPropagation();
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
return;
}
if (e.shiftKey) return; if (e.shiftKey) return;
// Ctrl+1..9 — jump to tab // Ctrl+1..9 — jump to tab
+94 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types"; import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -37,6 +37,10 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config"); invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () => export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles"); invoke<string[]>("list_aws_profiles");
/** Check a corporate CA path and report what would be installed. Never
* rejects for a bad path the reason comes back in `error`. */
export const inspectCaCertPath = (path: string) =>
invoke<CaCertInfo>("inspect_ca_cert_path", { path });
export const detectHostTimezone = () => export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone"); invoke<string>("detect_host_timezone");
@@ -178,6 +182,95 @@ export const getBrowserViewStatus = (projectId: string) =>
/** Probe for Playwright without starting anything — used to re-check after installing it. */ /** Probe for Playwright without starting anything — used to re-check after installing it. */
export const checkBrowserViewSupport = (projectId: string) => export const checkBrowserViewSupport = (projectId: string) =>
invoke<PlaywrightDetection>("check_browser_view_support", { projectId }); invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
/**
* Install `playwright` + `@playwright/cli` into the container's `/workspace`.
*
* A container mutation, so it only ever runs from an explicit click. Progress
* streams on the existing `container-progress` event; the result carries a
* fresh probe. Browsers are a separate action see below.
*/
export const installBrowserViewSupport = (projectId: string) =>
invoke<BrowserSetupOutcome>("install_browser_view_support", { projectId });
/**
* Install a browser and the apt libraries it needs, then verify it launches.
* `chromium` is Playwright's own build; `chrome` is the channel
* `@playwright/mcp` asks for. Hundreds of MB never call this implicitly.
*/
export const installBrowserViewBrowser = (
projectId: string,
browser: BrowserInstallTarget,
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
/**
* Detach the live view into its own OS window, or raise it if already open.
*
* Window-only: the viewer, the proxy and the container are untouched, so
* popping out and back costs nothing. The window loads the same token-bearing
* loopback URL as the pane, and has no IPC access.
*/
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
export const closeBrowserViewPopout = (projectId: string) =>
invoke<void>("close_browser_view_popout", { projectId });
/**
* Whether the pop-out is open and whether it is pinned, read from the window.
*
* Asked on every mount: the pane is unmounted whenever another Project Home
* sub-tab is selected, while the window carries on so neither fact can live
* in component state and survive.
*/
export const getBrowserViewPopoutState = (projectId: string) =>
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
/**
* Open a URL in a browser *inside* the container, published so the pane shows it.
*
* The same action serves an auth URL the OAuth callback listener is in the
* container too, so the loop closes without the host and a dev server on
* container loopback, which is how you watch a UI Claude is building. Only
* http/https; the backend rejects anything else.
*
* The viewer is started if it isn't already: asking for a page is asking to
* watch it, and leaving the user to go and press Start themselves with no
* hint that they had to is what the first version did.
*/
export const openPageInContainerBrowser = (
projectId: string,
url: string,
width: number,
height: number,
/** Also raise the pop-out window — for callers with no pane on screen. */
showWindow = false,
) =>
invoke<BrowserPageState>("open_page_in_container_browser", {
projectId,
url,
width,
height,
showWindow,
});
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
invoke<void>("set_container_page_viewport", { projectId, width, height });
export const getContainerPageState = (projectId: string) =>
invoke<BrowserPageState>("get_container_page_state", { projectId });
export const closeContainerPage = (projectId: string) =>
invoke<void>("close_container_page", { projectId });
/**
* 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.
*/
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
invoke<void>("set_browser_view_match_window", { projectId, enabled });
export const getBrowserViewMatchWindow = (projectId: string) =>
invoke<boolean>("get_browser_view_match_window", { projectId });
/** Pin the pop-out above other windows — the point of popping it out at all. */
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
// Shared Claude Code auth token — one `claude setup-token` run authenticates // Shared Claude Code auth token — one `claude setup-token` run authenticates
// every Anthropic-backend project. The token itself is never exposed here: it // every Anthropic-backend project. The token itself is never exposed here: it
+121 -1
View File
@@ -44,6 +44,10 @@ export interface Project {
/** null = not set → falls back to `full_permissions` (true → "bypass"). */ /** null = not set → falls back to `full_permissions` (true → "bypass"). */
permission_mode: PermissionMode | null; permission_mode: PermissionMode | null;
ssh_key_path: string | null; ssh_key_path: string | null;
/** Per-project override for the corporate CA certificate path (a single
* certificate file or a directory of them). null falls back to
* `AppSettings.ca_cert_path`. Changing it recreates the container. */
ca_cert_path: string | null;
git_token: string | null; git_token: string | null;
git_user_name: string | null; git_user_name: string | null;
git_user_email: string | null; git_user_email: string | null;
@@ -190,6 +194,12 @@ export interface GlobalOpenAiCompatibleSettings {
export interface AppSettings { export interface AppSettings {
default_ssh_key_path: string | null; default_ssh_key_path: string | null;
/** Corporate root CA a single certificate file or a directory of them
* mounted read-only into every container and installed into the system
* trust store, Node's `NODE_EXTRA_CA_CERTS`, Python's
* `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database.
* Needed when the host is behind a TLS-terminating corporate proxy. */
ca_cert_path: string | null;
default_git_user_name: string | null; default_git_user_name: string | null;
default_git_user_email: string | null; default_git_user_email: string | null;
docker_socket_path: string | null; docker_socket_path: string | null;
@@ -212,6 +222,20 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null; global_claude_code_settings: ClaudeCodeSettings | null;
} }
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
* in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */
export interface CaCertInfo {
exists: boolean;
is_directory: boolean;
cert_count: number;
/** The `.crt` names the certificates are installed as inside the container
* surfacing the silent `.pem` `.crt` rename that
* `update-ca-certificates` requires. */
installed_names: string[];
error: string | null;
}
export interface SttSettings { export interface SttSettings {
enabled: boolean; enabled: boolean;
model: string; model: string;
@@ -434,14 +458,49 @@ export interface PlaywrightDetection {
node_version: string | null; node_version: string | null;
playwright_version: string | null; playwright_version: string | null;
playwright_path: string | null; playwright_path: string | null;
/** Playwright's own `cli.js`, which installs browsers and their apt libraries. */
playwright_cli: string | null;
/** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */ /** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */
has_bind: boolean; has_bind: boolean;
cli_version: string | null; cli_version: string | null;
cli_entry: string | null; cli_entry: string | null;
/** Module roots the probe searched, echoed back for the "not found" message. */ /** Browser bundles in `~/.cache/ms-playwright`, e.g. `chromium-1200`. Never `ffmpeg-*`. */
browsers: string[];
/** Path to Google Chrome when the `chrome` channel what `@playwright/mcp`
* asks for is installed. It is an apt package, so it is never in `browsers`. */
chrome_channel: string | null;
/** The Chromium the *viewer's* Playwright would launch, and whether it exists. */
chromium_executable: string | null;
chromium_executable_exists: boolean;
/** What a script's `require("playwright")` resolves to routinely a different
* copy, pinning a different browser revision. If its Chromium is missing,
* every script Claude writes fails while the pane still looks green. */
script_playwright_version: string | null;
script_chromium_executable: string | null;
script_chromium_executable_exists: boolean;
/** Module roots the probe searched, echoed back for the "not found" message.
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
* Playwright installed through Claude Code's MCP setup actually lives. */
searched: string[]; searched: string[];
} }
/** Result of an install action. Mirrors Rust `BrowserSetupOutcome`. */
export interface BrowserSetupOutcome {
/** Fresh probe taken after the install, so the pane can update itself. */
detection: PlaywrightDetection;
/** Tail of the real npm/apt/Playwright output — shown instead of a generic message. */
log: string;
/** Whether a browser was actually started and closed. `null` when the step
* didn't try (the package step doesn't). */
browser_launched: boolean | null;
/** Something that didn't fail the action but the user still needs to know. */
warning: string | null;
}
/** Browsers the pane can install. `chromium` is Playwright's own build;
* `chrome` is the Google Chrome channel `@playwright/mcp` asks for. */
export type BrowserInstallTarget = "chromium" | "chrome";
/** Mirrors Rust `BrowserViewState` (serde snake_case). */ /** Mirrors Rust `BrowserViewState` (serde snake_case). */
export type BrowserViewState = "off" | "running" | "unavailable"; export type BrowserViewState = "off" | "running" | "unavailable";
@@ -464,6 +523,47 @@ export interface BrowserViewChangedEvent {
status: BrowserViewStatus; status: BrowserViewStatus;
} }
/**
* Mirrors Rust `PopoutState` read from the window, never remembered.
*
* The pane is unmounted whenever another Project Home sub-tab is selected while
* the window carries on, so anything it holds in component state is stale by
* the time the user comes back.
*/
export interface BrowserViewPopoutState {
open: boolean;
always_on_top: boolean;
}
/** Mirrors Rust `page::Viewport` — CSS pixels, clamped backend-side. */
export interface BrowserPageViewport {
width: number;
height: number;
}
/**
* Mirrors Rust `page::PageState`: what the container-side helper reports about
* the page Triple-C opened. `ready: false` with no error means there is none.
*/
export interface BrowserPageState {
ready: boolean;
url: string | null;
viewport: BrowserPageViewport | null;
error: string | null;
}
/**
* Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState`
* plus the project it belongs to.
*
* The pop-out window can close without the pane asking the user hits its X,
* or the session tears down and takes it so this is the only reliable way to
* know whether it is on screen.
*/
export interface BrowserViewPopoutChangedEvent extends BrowserViewPopoutState {
project_id: string;
}
/** Payload of the `claude-token-progress` event: milestones during /** Payload of the `claude-token-progress` event: milestones during
* `acquire_claude_token`. Never contains the token. */ * `acquire_claude_token`. Never contains the token. */
/** /**
@@ -502,6 +602,26 @@ export interface ClaudeTokenOutputEvent {
chunk: string; chunk: string;
} }
/** Payload of the `claude-token-link`: a sign-in URL taken from an OSC 8
* hyperlink parameter, which is the only place the CLI emits it whole the
* visible text is sliced to the terminal width. **Untrusted**: it is container
* output, so it goes through `sanitizeRelayUrl` with the
* `ANTHROPIC_SIGN_IN_HOSTS` allowlist before it is shown or opened. */
export interface ClaudeTokenLinkEvent {
project_id: string;
url: string;
}
/** Payload of `claude-token-code-rejected`: `claude setup-token` refused the
* submitted code and is parked waiting for another one. The flow is still
* alive, so this is recoverable `attempts_remaining` is how many more codes
* the backend will pass on before giving up. */
export interface ClaudeTokenCodeRejectedEvent {
project_id: string;
message: string;
attempts_remaining: number;
}
// ── Container base-image migration ─────────────────────────────────────────── // ── Container base-image migration ───────────────────────────────────────────
// //
// A project's container is created from its own `triple-c-snapshot-<id>:latest` // A project's container is created from its own `triple-c-snapshot-<id>:latest`
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { UrlDetector, flatten } from "./urlDetector";
const COLS = 80;
const enc = new TextEncoder();
/** Feed text and let the debounce + confirmation timers run. */
function feed(detector: UrlDetector, text: string) {
detector.feed(enc.encode(text));
vi.advanceTimersByTime(2000);
}
/** Hard-wrap the way a PTY does: a break every `cols` characters, nothing lost. */
function ptyWrap(text: string, cols = COLS): string {
const lines: string[] = [];
for (let i = 0; i < text.length; i += cols) lines.push(text.slice(i, i + cols));
return lines.join("\r\n");
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("flatten", () => {
it("rejoins a break the terminal inserted at the width", () => {
expect(flatten("abcde\nfghij", 5)).toBe("abcdefghij");
});
it("keeps a break that arrived before the width as a separator", () => {
expect(flatten("abc\ndef", 5)).toBe("abc def");
});
it("rejoins nothing when the width isn't known", () => {
// Better to lose a wrapped URL than to invent one.
expect(flatten("abcde\nfghij", 0)).toBe("abcde fghij");
});
});
describe("UrlDetector", () => {
it("reconstructs a URL the PTY hard-wrapped mid-token", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url =
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678&scope=openid+email+profile";
feed(d, "Open this link:\r\n" + ptyWrap(url) + "\r\nWaiting for the browser…\r\n");
expect(seen).toEqual([url]);
});
it("does not glue the text that follows a link onto it", () => {
// The bug this file exists for. A terminal wrapping a paragraph emits the
// break *instead of* the space, so deleting every break produced
// `…/tag/preview-63f3c54Butitprovesyournitpick…` — a different host and
// path from the one on screen, opened on the user's machine.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url =
"https://repo.anhonesthost.net/CyberCoveLLC/Triple-C/releases/tag/preview-63f3c54-with-a-long-enough-suffix-to-scan";
feed(
d,
[
url,
"But it proves your nitpick perfectly: every file in it says 0.3.0.",
"That is the hard-coded patch number.",
].join("\r\n") + "\r\n",
);
expect(seen).toEqual([url]);
expect(seen[0]).not.toContain("But");
// And the host is exactly what was printed — no characters lost.
expect(new URL(seen[0]).host).toBe("repo.anhonesthost.net");
});
it("stops at the end of a short line even when more output follows", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "a".repeat(90);
feed(d, `${url}\r\nnext line of output\r\n`);
expect(seen).toEqual([url]);
});
it("joins the next line when a token ends exactly at the width", () => {
// The one case the width rule cannot decide: a URL whose length is an exact
// multiple of the column count looks identical to one that was cut. Pinned
// as known behaviour rather than pretended away — the toast still shows the
// whole candidate, and nothing opens without the user pressing Open.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "c".repeat(2 * COLS - 20); // exactly 2 lines
feed(d, `${ptyWrap(url)}\r\nTAIL\r\n`);
expect(seen).toEqual([url + "TAIL"]);
});
it("ignores anything under the length threshold", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(d, "see https://example.com/short\r\nmore text\r\n");
expect(seen).toEqual([]);
});
it("emits a wrapped URL once, not once per chunk", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "b".repeat(120);
feed(d, ptyWrap(url));
feed(d, "\r\ndone\r\n");
expect(seen).toEqual([url]);
});
});
+61 -9
View File
@@ -3,8 +3,23 @@
* *
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width, * The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
* which breaks xterm.js WebLinksAddon URL detection. This class flattens * which breaks xterm.js WebLinksAddon URL detection. This class flattens
* the buffer (stripping PTY wraps, converting blank lines to spaces) and * the buffer (rejoining hard wraps, treating every other break as a
* matches URLs with a single regex, firing a callback for ones >= 100 chars. * terminator) and matches URLs with a single regex, firing a callback for ones
* >= 100 chars.
*
* ## Which line breaks may be deleted
*
* Only the ones the *terminal* inserted. A hard wrap happens at exactly the
* column width, so a line that reached the width was cut mid-token and its
* break must be removed to put the token back together; a line that stopped
* short ended for its own reasons and its break is a real separator.
*
* Deleting every break instead which this did glues unrelated output onto
* the end of a URL. Observed for real: a wrapped paragraph following a link
* became `…/tag/preview-63f3c54Butitprovesyournitpick…`, because a terminal
* that wraps at a space emits the break *instead of* the space, so removing
* the break removes the separator too. That candidate is a different URL from
* the one on screen, and the user is the one who has to notice.
* *
* When a URL match extends to the end of the flattened buffer, emission is * When a URL match extends to the end of the flattened buffer, emission is
* deferred (more chunks may still be arriving). A confirmation timer emits * deferred (more chunks may still be arriving). A confirmation timer emits
@@ -21,6 +36,45 @@ const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void; export type UrlCallback = (url: string) => void;
/**
* How wide the terminal is right now.
*
* A getter, not a number: the width changes with every window resize, and a
* stale one silently turns joining back into guesswork.
*/
export type ColumnsGetter = () => number;
/**
* Rejoin the line breaks the terminal inserted; turn the rest into spaces.
*
* A line of exactly `columns` visible characters was cut by the terminal, so
* its break is deleted and the two halves are put back together. Anything
* shorter ended on its own and becomes a space a URL cannot contain one, so
* that is also what stops a match running into whatever followed.
*
* `columns` of 0 or less means "not known yet"; nothing is rejoined, which
* costs a wrapped URL rather than inventing one.
*
* One case stays ambiguous and cannot be resolved here: a token that happens to
* end exactly at the width is indistinguishable from one the terminal cut, so
* the following line is joined to it. The candidate is still shown in full and
* confirmed by the user before anything opens.
*/
export function flatten(clean: string, columns: number): string {
const lines = clean.split(/\r?\n/);
let out = "";
for (let i = 0; i < lines.length; i++) {
out += lines[i];
if (i === lines.length - 1) break;
// `===`, not `>=`. A line *longer* than the width was never cut by the
// terminal — the stream simply contained no break there, so the break that
// follows it is the application's own and separates two things.
const wrapped = columns > 0 && lines[i].length === columns;
if (!wrapped) out += " ";
}
return out;
}
export class UrlDetector { export class UrlDetector {
private decoder = new TextDecoder(); private decoder = new TextDecoder();
private buffer = ""; private buffer = "";
@@ -29,9 +83,11 @@ export class UrlDetector {
private lastEmitted = ""; private lastEmitted = "";
private pendingUrl: string | null = null; private pendingUrl: string | null = null;
private callback: UrlCallback; private callback: UrlCallback;
private columns: ColumnsGetter;
constructor(callback: UrlCallback) { constructor(callback: UrlCallback, columns: ColumnsGetter) {
this.callback = callback; this.callback = callback;
this.columns = columns;
} }
/** Feed raw PTY output chunks. */ /** Feed raw PTY output chunks. */
@@ -61,12 +117,8 @@ export class UrlDetector {
// 1. Strip ANSI escape sequences // 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, ""); const clean = this.buffer.replace(ANSI_RE, "");
// 2. Flatten the buffer: // 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator) const flat = flatten(clean, this.columns());
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
const flat = clean
.replace(/(\r?\n){2,}/g, " ")
.replace(/[\r\n]/g, "");
if (!flat) return; if (!flat) return;
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
const A = homeTabKey("a");
const B = terminalTabKey("b");
const C = terminalTabKey("c");
function seed(tabOrder: string[], activeTabKey: string | null = null) {
useAppState.setState({
tabOrder,
activeTabKey,
activeSessionId: null,
selectedProjectId: null,
});
}
const order = () => useAppState.getState().tabOrder;
describe("tab reordering", () => {
beforeEach(() => seed([A, B, C]));
it("moves a tab to an earlier slot", () => {
useAppState.getState().moveTab(C, 0);
expect(order()).toEqual([C, A, B]);
});
it("moves a tab to a later slot", () => {
useAppState.getState().moveTab(A, 2);
expect(order()).toEqual([B, C, A]);
});
it("clamps a destination past the ends rather than dropping the tab", () => {
useAppState.getState().moveTab(A, 99);
expect(order()).toEqual([B, C, A]);
useAppState.getState().moveTab(A, -5);
expect(order()).toEqual([A, B, C]);
});
it("ignores a tab that isn't in the strip", () => {
useAppState.getState().moveTab("term:gone", 0);
expect(order()).toEqual([A, B, C]);
});
it("does not change what's active — dragging a tab is not selecting it", () => {
seed([A, B, C], B);
useAppState.getState().moveTab(C, 0);
const state = useAppState.getState();
expect(state.tabOrder).toEqual([C, A, B]);
expect(state.activeTabKey).toBe(B);
});
it("nudges the active tab with the keyboard, in both directions", () => {
seed([A, B, C], B);
useAppState.getState().moveActiveTab(-1);
expect(order()).toEqual([B, A, C]);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("stops the active tab at the ends instead of wrapping it around", () => {
seed([A, B, C], A);
useAppState.getState().moveActiveTab(-1);
// A held-down key must not teleport the tab to the far end.
expect(order()).toEqual([A, B, C]);
});
it("does nothing when no tab is active", () => {
seed([A, B, C], null);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("keeps Ctrl+1..9 addressing the strip as reordered", () => {
seed([A, B, C], A);
useAppState.getState().moveTab(C, 0);
useAppState.getState().focusTabIndex(0);
expect(useAppState.getState().activeTabKey).toBe(C);
});
});
+59
View File
@@ -71,10 +71,26 @@ interface AppState {
tabOrder: string[]; tabOrder: string[];
activeTabKey: string | null; activeTabKey: string | null;
openProjectHome: (projectId: string) => void; openProjectHome: (projectId: string) => void;
/**
* Open a project's home tab *on a particular sub-tab*.
*
* The sub-tab is local state inside `ProjectHome`, so this parks a request
* here for it to pick up: an action taken somewhere else entirely opening a
* page in the container's browser from a terminal has to be able to land
* the user on the pane that shows the result.
*/
openProjectHomeTab: (projectId: string, tab: string) => void;
/** Consumed once by `ProjectHome`, then cleared. */
pendingHomeTab: { projectId: string; tab: string } | null;
clearPendingHomeTab: () => void;
closeHomeTab: (projectId: string) => void; closeHomeTab: (projectId: string) => void;
setActiveTabKey: (key: string) => void; setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void; cycleTab: (delta: number) => void;
focusTabIndex: (index: number) => void; focusTabIndex: (index: number) => void;
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
moveTab: (key: string, toIndex: number) => void;
/** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void;
// Inline container progress, replacing the blocking progress modal. // Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>; containerProgress: Record<string, string>;
@@ -231,6 +247,20 @@ export const useAppState = create<AppState>((set) => ({
...activation(key), ...activation(key),
}; };
}), }),
openProjectHomeTab: (projectId, tab) =>
set((state) => {
const key = homeTabKey(projectId);
return {
selectedProjectId: projectId,
tabOrder: state.tabOrder.includes(key)
? state.tabOrder
: [...state.tabOrder, key],
pendingHomeTab: { projectId, tab },
...activation(key),
};
}),
pendingHomeTab: null,
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
closeHomeTab: (projectId) => closeHomeTab: (projectId) =>
set((state) => { set((state) => {
const key = homeTabKey(projectId); const key = homeTabKey(projectId);
@@ -274,6 +304,35 @@ export const useAppState = create<AppState>((set) => ({
? { ...patch, selectedProjectId: tabKeyId(key) } ? { ...patch, selectedProjectId: tabKeyId(key) }
: patch; : patch;
}), }),
// Reordering is deliberately *only* a reordering: dragging a tab does not
// select it, so a drag can be aimed at a background tab without yanking the
// main area (and a running terminal's focus) away mid-gesture.
moveTab: (key, toIndex) =>
set((state) => {
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
moveActiveTab: (delta) =>
set((state) => {
const key = state.activeTabKey;
if (!key) return {};
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
// Clamped, not wrapped: a tab dragged off the end would otherwise
// reappear at the other end, which reads as a bug on a held-down key.
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
// Container progress // Container progress
containerProgress: {}, containerProgress: {},
+96
View File
@@ -25,6 +25,7 @@ RUN for i in 1 2 3 4 5; do \
jq \ jq \
sudo \ sudo \
ca-certificates \ ca-certificates \
libnss3-tools \
gnupg \ gnupg \
locales \ locales \
unzip \ unzip \
@@ -35,6 +36,12 @@ RUN for i in 1 2 3 4 5; do \
socat \ socat \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither
# /etc/ssl/certs nor $SSL_CERT_FILE — they have their own NSS database at
# ~/.pki/nssdb — so without it the browser-view pane cannot be made to trust a
# corporate CA, no matter what the system trust store says. entrypoint.sh
# degrades to a warning if it is ever missing.
# Remove default ubuntu user to free UID 1000 for host-user remapping # Remove default ubuntu user to free UID 1000 for host-user remapping
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi && if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
@@ -78,6 +85,95 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm && npm install -g pnpm
# ── Browser runtime libraries (Chromium / Google Chrome) ────────────────────
# Chromium links against a set of shared libraries Ubuntu's base image does not
# ship — libnss3, libgbm1, libatk*, libasound2t64, libcups2t64, libpango,
# libdrm2 and friends. Without them `playwright install chromium` downloads a
# browser that then dies at launch with "Host system is missing dependencies:
# libnss3.so", which reads like a Playwright bug and is not one. Installing
# google-chrome-stable used to look like the fix only because apt pulled these
# in as *its* dependencies.
#
# ## Why baked, and why only the libraries
#
# A runtime `apt-get install` lands in the container's writable layer: it is
# re-paid after every project Reset, and it is *lost* on base-image migration,
# which replays apt from a manifest against the new base. The browsers
# themselves live in ~/.cache/ms-playwright, inside the home volume, and survive
# both — so the runtime approach converges on the worst state, a 400 MB browser
# present with its libraries gone. Baking the libraries and leaving the browsers
# out puts each half where it already persists.
#
# Browser binaries are deliberately NOT baked: they are large, they are
# version-coupled to whatever Playwright the user installs, and the home volume
# already keeps them.
#
# ## Why `install-deps` rather than a hand-written apt list
#
# Playwright names its own dependencies, so the list cannot silently rot. That
# matters more than usual on Ubuntu 24.04, whose 64-bit-time_t transition
# renamed a swathe of these packages (libasound2 → libasound2t64, libatk1.0-0 →
# libatk1.0-0t64, libglib2.0-0 → libglib2.0-0t64, …); a hardcoded list drifts
# into "E: Unable to locate package" build failures, and a list that predates a
# new Chromium dependency drifts into exactly the launch failure this layer
# exists to prevent.
#
# Verified on a real `--platform linux/arm64` build of this file, not assumed:
# it resolves and installs there too (99 packages on both arches), and the
# --dry-run assertion below passes. Worth checking rather than assuming:
# Playwright looks its dependency list up under `<distro><version>-<arch>`, so
# arm64 is a separate lookup that could have missed.
#
# ## What it costs
#
# Measured with this layer applied on top of an otherwise identical image
# (linux/amd64, playwright 1.62.1): **+99 packages, +334 MiB unpacked, +119 MiB
# compressed** — the image goes 2950 → 3284 MiB unpacked, 759 → 878 MiB
# compressed. (`docker history` calls the layer 361 MB, i.e. 344 MiB; the
# difference is tar metadata `du` doesn't count.)
#
# Where it goes, by dpkg Installed-Size:
# ~213 MiB libllvm20 + mesa-libgallium + libicu74. Not optional and not
# avoidable by trimming the list: libgbm1, which Chromium genuinely
# needs, Depends on mesa-libgallium, which Depends on libllvm20.
# ~94 MiB Playwright's `tools` group — xvfb and the CJK/emoji fonts. Kept:
# the base image ships no fonts at all, so without them every page
# this feature exists to display renders as tofu, and xvfb is what
# lets a *headed* browser run in here.
# the rest Chromium's own library closure.
#
# An explicit apt list of just `chromium`'s dependencies measures 247 MiB
# installed against install-deps' 341 MiB, so hand-maintaining one would save
# ~94 MiB. Not worth owning the drift; if you disagree, derive the list from
# `install-deps --dry-run chromium` and pin the Playwright version you took it
# from in a comment here.
#
# The retry loop is for the same transient mirror-sync failures the other apt
# layers guard against; install-deps runs its own un-retried `apt-get update`
# internally. `npx --yes` is what makes it non-interactive, and the version it
# resolved is printed so a build log says which Playwright named this set.
#
# Placed immediately after Node (npx is its only prerequisite) and well above
# the shim COPYs, so editing a shim at the bottom of this file does not re-run a
# multi-hundred-megabyte apt install.
#
# `--dry-run` afterwards is the build-time assertion, and it is 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
# this check that failure mode would ship an image whose build log looked clean.
# `--dry-run` exits non-zero if any required package is still missing.
RUN npx --yes playwright@latest --version \
&& ok=0 \
&& for i in 1 2 3 4 5; do \
if npx --yes playwright@latest install-deps chromium; then ok=1; break; fi; \
echo "install-deps failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& [ "$ok" = 1 ] \
&& npx --yes playwright@latest install-deps --dry-run chromium \
&& rm -rf /var/lib/apt/lists/* /root/.npm
# ── Python 3 + pip + uv + ruff ────────────────────────────────────────────── # ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
RUN for i in 1 2 3 4 5; do \ RUN for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \ apt-get -o Acquire::Retries=3 update && break; \
+162 -1
View File
@@ -58,6 +58,167 @@ remap_uid_gid
# Fix ownership of home directory after UID/GID change # Fix ownership of home directory after UID/GID change
chown -R claude:claude /home/claude chown -R claude:claude /home/claude
# ── Corporate CA certificates ───────────────────────────────────────────────
# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C
# mounts a *directory* as-is and a *single file* as /tmp/.host-ca/<name>.crt,
# so this only ever has to deal with a directory (the file branch below is
# defensive).
#
# Runs before everything that touches the network — the git credential helper,
# ssh-keyscan, and especially the `claude update` at the bottom of this file,
# which is itself an HTTPS call that fails behind a TLS-terminating proxy
# without this.
#
# Two things are easy to get wrong here:
# 1. `update-ca-certificates` globs /usr/local/share/ca-certificates/*.crt
# case-sensitively. A `.pem` that is merely copied in is ignored in total
# silence, so certificates are *renamed*, not copied.
# 2. Chrome/Chromium read neither /etc/ssl nor $SSL_CERT_FILE; they have
# their own NSS database at ~/.pki/nssdb, seeded below with certutil.
#
# NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE / SSL_CERT_FILE are deliberately NOT
# exported here. Every terminal is a separate `docker exec`, which inherits the
# container's configured env and sees nothing this script exported — the same
# reason $BROWSER had to become an image-level ENV. Triple-C sets them on the
# container at creation time instead. (They are forwarded into the cron
# environment file further down, because cron jobs start from a bare env.)
CA_SRC="/tmp/.host-ca"
CA_STORE="/usr/local/share/ca-certificates"
CA_PREFIX="triple-c-"
CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
CA_STAMP="/var/lib/triple-c/ca.stamp"
CA_NSSDB="/home/claude/.pki/nssdb"
# Mirror of `container_cert_name()` in app/src-tauri/src/docker/ca_certs.rs.
# The two must agree; the Rust side has the unit tests.
ca_normalise_name() {
local name stem
name=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')
while [ "${name#.}" != "$name" ]; do name="${name#.}"; done
stem="${name%.*}"
[ -z "$stem" ] && stem="corporate-ca"
printf '%s.crt' "$stem"
}
ca_source_files() {
if [ -d "$CA_SRC" ]; then
find "$CA_SRC" -maxdepth 1 -type f \
\( -iname '*.crt' -o -iname '*.pem' -o -iname '*.cer' \
-o -iname '*.cert' -o -iname '*.ca-bundle' \) 2>/dev/null | sort
elif [ -f "$CA_SRC" ]; then
printf '%s\n' "$CA_SRC"
fi
}
# Seed Chrome/Chromium's NSS database. Tolerant by design: a missing certutil
# or a broken profile must warn, never fail the container start.
# ~/.pki lives in the home volume, so this persists once done; the system store
# lives in the writable layer and is re-applied on every start.
ca_seed_nssdb() {
if ! command -v certutil >/dev/null 2>&1; then
echo "entrypoint: warning — certutil not found (install libnss3-tools); Chrome/Chromium in this container will not trust the corporate CA"
return 0
fi
su -s /bin/bash claude -c '
db="$HOME/.pki/nssdb"
mkdir -p "$db" || exit 1
if [ ! -f "$db/cert9.db" ]; then
certutil -d "sql:$db" -N --empty-password >/dev/null 2>&1 || exit 1
fi
for f in /usr/local/share/ca-certificates/triple-c-*.crt; do
[ -f "$f" ] || continue
nick="triple-c:$(basename "$f" .crt)"
# Delete first so re-running replaces rather than duplicates.
certutil -d "sql:$db" -D -n "$nick" >/dev/null 2>&1
certutil -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1 \
|| echo "entrypoint: warning — certutil could not add $nick"
done
' && echo "entrypoint: seeded Chrome/Chromium NSS database with the corporate CA" \
|| echo "entrypoint: warning — NSS database seeding failed (continuing)"
}
install_corporate_ca() {
local files fp stamp f base name count installed
files=$(ca_source_files)
if [ -z "$files" ]; then
# Nothing configured — but /usr/local/share is in the writable layer and
# `docker commit` bakes it into the project's snapshot image, so a cert
# installed by a previous configuration would ride that snapshot into
# every future container. Turning the setting off has to actively undo.
if ls "$CA_STORE/$CA_PREFIX"*.crt >/dev/null 2>&1; then
echo "entrypoint: removing previously installed corporate CA certificates"
rm -f "$CA_STORE/$CA_PREFIX"*.crt
update-ca-certificates --fresh >/dev/null 2>&1 \
|| echo "entrypoint: warning — update-ca-certificates failed while removing certificates"
rm -f "$CA_STAMP"
fi
if [ -e "$CA_SRC" ]; then
echo "entrypoint: warning — $CA_SRC holds no certificate files"
fi
return 0
fi
# Idempotent and cheap: the certs are already installed on a plain restart
# (the writable layer survives stop/start), so hash the sources and skip the
# work when nothing has moved. The NSS database is checked separately
# because it lives in the home volume and can be wiped independently.
fp=$(printf '%s\n' "$files" | xargs -d '\n' -r sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1)
stamp=$(cat "$CA_STAMP" 2>/dev/null)
if [ -n "$fp" ] && [ "$fp" = "$stamp" ] && [ -s "$CA_BUNDLE" ]; then
if [ -f "$CA_NSSDB/cert9.db" ]; then
echo "entrypoint: corporate CA certificates already installed"
return 0
fi
ca_seed_nssdb
return 0
fi
mkdir -p "$CA_STORE" "$(dirname "$CA_STAMP")"
rm -f "$CA_STORE/$CA_PREFIX"*.crt
installed=0
while IFS= read -r f; do
[ -n "$f" ] || continue
base=$(basename "$f")
name="$CA_PREFIX$(ca_normalise_name "$base")"
count=$(grep -c -- '-----BEGIN CERTIFICATE-----' "$f" 2>/dev/null || true)
[ -n "$count" ] || count=0
if [ "$count" -gt 1 ]; then
# A corporate trust chain is usually delivered as one PEM holding
# root + intermediates. update-ca-certificates handles exactly one
# certificate per file, so split it.
awk -v out="$CA_STORE/${name%.crt}" '
/-----BEGIN CERTIFICATE-----/ { n++; f = out "-" n ".crt" }
n > 0 { print > f }
' "$f" && installed=$((installed + count))
elif [ "$count" -eq 1 ]; then
cp -f "$f" "$CA_STORE/$name" && installed=$((installed + 1))
else
echo "entrypoint: warning — $f holds no PEM certificate (DER is not supported), skipping"
fi
done <<< "$files"
chmod 644 "$CA_STORE/$CA_PREFIX"*.crt 2>/dev/null
if [ "$installed" -eq 0 ]; then
echo "entrypoint: warning — no usable certificates found under $CA_SRC"
return 0
fi
if update-ca-certificates >/dev/null 2>&1; then
echo "entrypoint: installed $installed corporate CA certificate(s) into the system trust store"
printf '%s' "$fp" > "$CA_STAMP"
else
echo "entrypoint: warning — update-ca-certificates failed; corporate certificates may not be trusted"
fi
ca_seed_nssdb
}
install_corporate_ca
# ── SSH key setup ────────────────────────────────────────────────────────── # ── SSH key setup ──────────────────────────────────────────────────────────
# Host SSH dir is mounted read-only at /tmp/.host-ssh. # Host SSH dir is mounted read-only at /tmp/.host-ssh.
# Copy to /home/claude/.ssh so we can fix permissions. # Copy to /home/claude/.ssh so we can fix permissions.
@@ -277,7 +438,7 @@ ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE" : > "$ENV_FILE"
env | while IFS='=' read -r key value; do env | while IFS='=' read -r key value; do
case "$key" in case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER) ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
# Escape single quotes in value and write as KEY='VALUE' # Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"