Compare commits

...
Author SHA1 Message Date
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
33 changed files with 4597 additions and 175 deletions
+33
View File
@@ -395,6 +395,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 && (
+77 -1
View File
@@ -104,6 +104,32 @@ 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.
- **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 +160,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 +201,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.
+42 -2
View File
@@ -509,6 +509,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 +828,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 +859,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.
@@ -1127,6 +1161,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
+76 -22
View File
@@ -4,6 +4,7 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use crate::browser_view::install::{self, BrowserSetupOutcome};
use crate::browser_view::{manager, BrowserViewStatus}; use crate::browser_view::{manager, BrowserViewStatus};
use crate::AppState; use crate::AppState;
@@ -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,84 @@ 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
}
/// 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)
}
+288 -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,25 @@ 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>,
/// 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 +106,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 +122,66 @@ 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
} }
/// 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.browsers.is_empty()
&& self.chrome_channel.is_none()
}
/// 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 +226,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 +271,16 @@ 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){}"#,
// 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 +313,158 @@ 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] #[test]
@@ -252,6 +495,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
+1
View File
@@ -63,6 +63,7 @@
pub mod commands; pub mod commands;
pub mod detect; pub mod detect;
pub mod install;
pub mod proxy; pub mod proxy;
use std::collections::HashMap; use std::collections::HashMap;
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.
+3
View File
@@ -426,6 +426,8 @@ 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,
// 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 +439,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,23 +1,41 @@
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 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),
})); }));
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 +49,33 @@ 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,
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,7 +108,9 @@ const project: Project = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
storeState.containerProgress = {};
getBrowserViewStatus.mockResolvedValue(OFF); getBrowserViewStatus.mockResolvedValue(OFF);
checkBrowserViewSupport.mockResolvedValue(READY);
}); });
describe("BrowserTab", () => { describe("BrowserTab", () => {
@@ -72,17 +119,24 @@ describe("BrowserTab", () => {
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 +163,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 +287,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 +304,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,
+347 -23
View File
@@ -1,15 +1,22 @@
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,
BrowserViewStatus, BrowserViewStatus,
PlaywrightDetection,
Project, Project,
} from "../../../lib/types"; } from "../../../lib/types";
import { import {
checkBrowserViewSupport,
getBrowserViewStatus, getBrowserViewStatus,
installBrowserViewBrowser,
installBrowserViewSupport,
setBrowserViewEnabled, setBrowserViewEnabled,
} from "../../../lib/tauri-commands"; } from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
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";
@@ -29,6 +36,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 +48,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 +61,14 @@ 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);
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
@@ -77,6 +100,11 @@ export default function BrowserTab({ project, active }: Props) {
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 +129,52 @@ 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 /** Run one install. Every path clears the progress line it started. */
// than offering a control that would only fail. 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 +185,16 @@ 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.browsers.length === 0 && probed.chrome_channel === null;
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">
@@ -151,7 +233,7 @@ export default function BrowserTab({ project, active }: Props) {
<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"}
@@ -169,8 +251,23 @@ export default function BrowserTab({ project, active }: Props) {
/> />
) : ( ) : (
<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>
@@ -190,25 +287,214 @@ export default function BrowserTab({ project, active }: Props) {
); );
} }
/** 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;
}
/** What the container is short of, as a list rather than as prose. */
function missingParts(d: PlaywrightDetection | null): string[] {
if (!d) return [];
const out: string[] = [];
if (!d.node_version) out.push("Node.js");
if (!d.playwright_version) out.push("playwright");
else if (!d.has_bind) out.push("a newer playwright — this build has no browser.bind()");
if (!d.cli_entry) out.push("@playwright/cli");
return out;
}
/**
* Setup, as one action per line, each saying what it costs before it is
* pressed.
*
* The old pane printed npm commands here and left the rest to the user. The
* result, verified with a real one: an `@playwright/mcp` install that could
* never satisfy this pane, a global install that hit EACCES, a Chromium that
* downloaded and then would not start because the image shipped none of its
* shared libraries, and a long tail of commands after that. Current base images
* bake those libraries in, so that last one is fixed at the source but a
* project keeps its original base image until it is migrated, so the install
* action still handles a container that lacks them.
*/
function Setup({
detection,
message,
job,
progress,
outcome,
error,
onInstall,
}: {
detection: PlaywrightDetection | null;
message: string | null;
job: SetupJob;
progress?: string;
outcome: BrowserSetupOutcome | null;
error: string | null;
onInstall: (which: Exclude<SetupJob, null>) => void;
}) {
const busy = job !== null;
const havePackages = isUsable(detection);
const missing = missingParts(detection);
const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null;
const noBrowser = browsers.length === 0 && chrome === null;
return ( return (
<div className="p-4 max-w-[46rem] space-y-3"> <div className="p-4 max-w-[46rem] space-y-4">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]"> <div>
This container cant serve a browser view yet <h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
</h2> {!havePackages
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed"> ? "This container cant serve a browser view yet"
{status.message} : noBrowser
</p> ? "Playwright is ready — but theres no browser to drive yet"
{d && ( : "This container is set up"}
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]"> </h2>
<Detail label="Node.js" value={d.node_version} /> <p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
<Detail label="Playwright" value={d.playwright_version} /> {message ??
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} /> (missing.length > 0
<Detail label="@playwright/cli" value={d.cli_version} /> ? `Missing: ${missing.join(", ")}.`
{d.searched.length > 0 && ( : noBrowser
<Detail label="Searched" value={d.searched.join(", ")} /> ? "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 +502,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 (
<> <>
@@ -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>
+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,
}; };
} }
+23 -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, 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,24 @@ 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 });
// 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
+71 -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,40 @@ 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;
/** 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";
@@ -502,6 +552,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`
+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"