Add llama.cpp backend, model gateway, URL relay and browser view

Four features, plus a latent bug fix.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 16:55:28 -07:00
co-authored by Claude Opus 5
parent 7d00390e1f
commit cc5f691677
46 changed files with 6194 additions and 61 deletions
+27 -2
View File
@@ -97,12 +97,24 @@ 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`.
Binds `0.0.0.0` — unlike STT — because *project containers*, not the host process, consume
it; it therefore **always** sets a LiteLLM `master_key`, since LiteLLM without one accepts
any key.
- `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 +122,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/`)
@@ -135,7 +147,20 @@ Per-project, independently configured:
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
- **llama.cpp** — Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080`, its default port)
- **OpenAI Compatible** — Connect through a gateway implementing the **Anthropic Messages API** (LiteLLM) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
**Claude Code only ever speaks the Anthropic Messages API** (`POST /v1/messages?beta=true`) to
`ANTHROPIC_BASE_URL` — never OpenAI's `/v1/chat/completions`. Ollama and llama.cpp implement
`/v1/messages` natively, which is why each gets a plain base-URL backend with no translation shim.
A server that only exposes an OpenAI-shaped API does not work behind any backend.
For every backend pointing at a custom endpoint (`Backend::uses_custom_endpoint`), all four
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` vars are pinned to the backend's configured
model id, with an optional per-backend Haiku override. Without this, Claude Code's background
calls resolve `haiku` to an Anthropic model id the local server does not have and fail silently.
Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and must not be used.
## Styling
+219 -12
View File
@@ -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.
---
@@ -505,7 +516,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 +538,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 +707,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 +721,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 +1125,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 +1175,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
+93 -4
View File
@@ -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)
+17 -3
View File
@@ -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
@@ -443,7 +456,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
+51 -4
View File
@@ -362,14 +362,43 @@ 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
}
// ─────────────────────────────────────────────────────────────────────────────
// 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;
/// 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(
@@ -519,7 +548,25 @@ 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_range_is_skipped() {
let skip = skipped_ports(&project_with_mappings(vec![]));
assert_eq!(skip.len(), RESERVED_CONTAINER_PORTS.clone().count());
}
#[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));
}
}
+25
View File
@@ -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
}
+263
View File
@@ -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));
}
}
+839
View File
@@ -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());
}
}
+699
View File
@@ -0,0 +1,699 @@
//! 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 = 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;
}
};
let head_text = String::from_utf8_lossy(&head).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.
async fn read_head(stream: &mut TcpStream) -> Result<Vec<u8>, 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 find_head_end(&buf).is_some() {
return Ok(buf);
}
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();
match name.trim().to_ascii_lowercase().as_str() {
"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 {
use super::*;
const TOKEN: &str = "s3cr3t-token-value";
fn origins() -> Vec<String> {
self_origins(47820)
}
fn authorities() -> Vec<String> {
host_authorities(47820)
}
fn head(request_line: &str, headers: &[&str]) -> String {
let mut s = String::from(request_line);
s.push_str("\r\n");
for h in headers {
s.push_str(h);
s.push_str("\r\n");
}
s.push_str("\r\n");
s
}
fn verdict(request_line: &str, headers: &[&str]) -> Verdict {
authorize(&head(request_line, headers), TOKEN, &origins(), &authorities())
}
#[test]
fn the_initial_document_is_allowed_by_its_query_token() {
assert_eq!(
verdict(
&format!("GET /?token={} HTTP/1.1", TOKEN),
&["Host: 127.0.0.1:47820"]
),
Verdict::Allow
);
}
#[test]
fn a_wrong_token_is_not_enough() {
assert_eq!(
verdict("GET /?token=nope HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Unauthenticated
);
}
#[test]
fn a_subresource_is_allowed_by_the_token_in_its_referer() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
&format!("Referer: http://127.0.0.1:47820/?token={}", TOKEN),
]
),
Verdict::Allow
);
}
#[test]
fn the_websocket_upgrade_is_allowed_by_its_own_origin() {
// The viewer's CDP screencast socket carries Origin and no Referer, and
// its URL is not ours to add a token to.
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
"Upgrade: websocket",
"Connection: Upgrade",
"Origin: http://127.0.0.1:47820",
]
),
Verdict::Allow
);
}
#[test]
fn a_subresource_is_allowed_by_fetch_metadata_when_the_referer_is_stripped() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
"Sec-Fetch-Site: same-origin",
"Sec-Fetch-Dest: script",
]
),
Verdict::Allow
);
}
#[test]
fn cross_site_fetch_metadata_is_refused() {
for site in ["cross-site", "same-site", "none"] {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&["Host: 127.0.0.1:47820", &format!("Sec-Fetch-Site: {}", site)]
),
Verdict::Unauthenticated,
"Sec-Fetch-Site: {}",
site
);
}
}
#[test]
fn a_hostile_pages_fetch_is_refused_by_its_origin() {
assert_eq!(
verdict(
"GET / HTTP/1.1",
&["Host: 127.0.0.1:47820", "Origin: http://evil.example"]
),
Verdict::Unauthenticated
);
}
#[test]
fn a_bare_port_scan_is_refused() {
// No token, no Origin, no Referer — a cross-site <script src>, a
// top-level navigation, or curl.
assert_eq!(
verdict("GET / HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Unauthenticated
);
}
#[test]
fn dns_rebinding_is_refused_even_with_a_valid_token() {
// The attacker's name resolves to 127.0.0.1, but the Host header still
// says who the browser thinks it is talking to.
assert_eq!(
verdict(
&format!("GET /?token={} HTTP/1.1", TOKEN),
&["Host: evil.example:47820"]
),
Verdict::BadHost
);
}
#[test]
fn localhost_is_an_acceptable_authority_and_origin() {
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["Host: localhost:47820", "Origin: http://localhost:47820"]
),
Verdict::Allow
);
}
#[test]
fn another_panes_origin_does_not_authorize_this_one() {
// Ports are what separate one project's pane from another's, so the
// neighbouring port must not be accepted as "self".
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["Host: 127.0.0.1:47820", "Origin: http://127.0.0.1:47821"]
),
Verdict::Unauthenticated
);
}
#[test]
fn a_cookie_borne_token_is_accepted() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
&format!("Cookie: other=1; {}={}", COOKIE_NAME, TOKEN),
]
),
Verdict::Allow
);
}
#[test]
fn a_missing_host_header_is_refused() {
assert_eq!(
verdict(&format!("GET /?token={} HTTP/1.1", TOKEN), &[]),
Verdict::BadHost
);
}
#[test]
fn header_names_are_matched_case_insensitively() {
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["HOST: 127.0.0.1:47820", "ORIGIN: http://127.0.0.1:47820"]
),
Verdict::Allow
);
}
#[test]
fn a_connect_request_cannot_turn_this_into_a_forward_proxy() {
assert_eq!(
verdict("CONNECT evil.example:443 HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Malformed
);
}
#[test]
fn an_empty_head_is_malformed() {
assert_eq!(authorize("", TOKEN, &origins(), &authorities()), Verdict::Malformed);
}
#[test]
fn the_head_terminator_is_found_for_both_crlf_and_lf() {
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\n"), Some(18));
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\n"), Some(16));
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
}
#[test]
fn query_token_ignores_lookalike_parameters() {
assert_eq!(query_token("/?mytoken=a&token=b"), Some("b"));
assert_eq!(query_token("/?tokenish=a"), None);
assert_eq!(query_token("/nothing"), None);
}
#[test]
fn tokens_match_rejects_prefixes_and_suffixes() {
assert!(tokens_match(TOKEN, TOKEN));
assert!(!tokens_match(&TOKEN[..5], TOKEN));
assert!(!tokens_match(&format!("{}x", TOKEN), TOKEN));
assert!(!tokens_match("", TOKEN));
}
#[test]
fn the_proxy_port_range_is_the_one_the_csp_enumerates() {
// tauri.conf.json lists these origins in `frame-src`; a change here
// without a change there yields a pane that is silently blocked.
assert_eq!(PROXY_PORTS.clone().count(), 8);
assert_eq!(*PROXY_PORTS.start(), 47820);
assert_eq!(*PROXY_PORTS.end(), 47827);
}
}
@@ -0,0 +1,88 @@
//! Tauri commands for the model gateway container.
//!
//! Mirrors `stt_commands`. The one rule that is specific to this module: the
//! **provider API key never crosses back to the frontend**. It goes in through
//! `set_gateway_api_key`, lives in the OS keychain, and is only ever read
//! host-side when rendering the gateway config. `get_gateway_status` reports
//! its presence as a boolean.
//!
//! The gateway *master key* is different and is returned deliberately — it is
//! the value the user has to paste into a project's model config as its auth
//! token, so keeping it hidden would just make the feature unusable.
use tauri::{AppHandle, Emitter, State};
use crate::docker::gateway;
use crate::models::GatewayStatus;
use crate::storage::secure;
use crate::AppState;
#[tauri::command]
pub async fn get_gateway_status(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
let settings = state.settings_store.get();
gateway::get_gateway_status(&settings.gateway).await
}
#[tauri::command]
pub async fn start_gateway(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
let settings = state.settings_store.get();
gateway::ensure_gateway_running(&settings.gateway).await
}
#[tauri::command]
pub async fn stop_gateway() -> Result<(), String> {
gateway::stop_gateway_container().await
}
/// Whether the gateway is actually answering yet. LiteLLM needs a few seconds
/// after the container starts before `/v1/messages` will serve anything.
#[tauri::command]
pub async fn check_gateway_health(state: State<'_, AppState>) -> Result<bool, String> {
let settings = state.settings_store.get();
gateway::check_gateway_health(settings.gateway.port).await
}
#[tauri::command]
pub async fn build_gateway_image(app_handle: AppHandle) -> Result<(), String> {
gateway::build_gateway_image(move |msg| {
let _ = app_handle.emit("gateway-build-progress", &msg);
})
.await
}
#[tauri::command]
pub async fn pull_gateway_image(app_handle: AppHandle) -> Result<(), String> {
gateway::pull_gateway_image(move |msg| {
let _ = app_handle.emit("gateway-pull-progress", &msg);
})
.await
}
/// Store the upstream provider API key. Write-only from the frontend's point
/// of view — there is no matching getter.
#[tauri::command]
pub async fn set_gateway_api_key(api_key: String) -> Result<(), String> {
secure::store_gateway_api_key(&api_key)
}
/// Forget the provider API key. The gateway keeps serving until it is
/// restarted, at which point it will refuse to start without a key.
#[tauri::command]
pub async fn clear_gateway_api_key() -> Result<(), String> {
secure::delete_gateway_api_key()
}
/// The token a project sends to the gateway (`ANTHROPIC_AUTH_TOKEN`), minting
/// one on first use.
#[tauri::command]
pub async fn get_gateway_auth_token() -> Result<String, String> {
secure::get_or_create_gateway_master_key()
}
/// Mint a new gateway auth token, invalidating the old one. Projects still
/// holding the previous value stop working until they are updated, and the
/// gateway is recreated on its next start because the rotation id moved.
#[tauri::command]
pub async fn regenerate_gateway_auth_token() -> Result<String, String> {
secure::regenerate_gateway_master_key()
}
+1
View File
@@ -3,6 +3,7 @@ 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;
@@ -207,6 +207,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())?;
@@ -314,6 +324,7 @@ pub async fn start_project_container(
&project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
@@ -356,6 +367,7 @@ pub async fn start_project_container(
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
@@ -393,6 +405,7 @@ pub async fn start_project_container(
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
+449 -3
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -194,8 +194,89 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"MISSION_CONTROL_ENABLED",
"TRIPLE_C_PERMISSION_MODE",
CLAUDE_OAUTH_TOKEN_ENV,
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
// above; they are listed explicitly so that a future narrowing of the
// prefix list cannot silently unreserve them, and so `is_reserved_env_key`
// reads as the single, complete statement of what Triple-C owns.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
];
/// Claude Code's model-alias env vars. Each names the concrete model id that
/// one of the `opus` / `sonnet` / `haiku` / `fable` aliases resolves to.
///
/// `ANTHROPIC_DEFAULT_HAIKU_MODEL` is the important one: it is documented as
/// *"Model ID that the `haiku` alias resolves to, also used for background
/// functionality"* — conversation titles, summarisation, and other out-of-band
/// calls. Left unset against a local server, Claude Code sends
/// Anthropic's own Haiku model id to a server that has never heard of it and
/// every background call fails, usually silently.
///
/// (`ANTHROPIC_SMALL_FAST_MODEL` is the deprecated predecessor of the Haiku
/// var and is deliberately *not* used.)
pub const ANTHROPIC_DEFAULT_OPUS_MODEL: &str = "ANTHROPIC_DEFAULT_OPUS_MODEL";
pub const ANTHROPIC_DEFAULT_SONNET_MODEL: &str = "ANTHROPIC_DEFAULT_SONNET_MODEL";
pub const ANTHROPIC_DEFAULT_HAIKU_MODEL: &str = "ANTHROPIC_DEFAULT_HAIKU_MODEL";
pub const ANTHROPIC_DEFAULT_FABLE_MODEL: &str = "ANTHROPIC_DEFAULT_FABLE_MODEL";
/// Resolve the four `ANTHROPIC_DEFAULT_*_MODEL` values for a backend that
/// points Claude Code at a custom endpoint.
///
/// All four aliases fall back to `effective_model` — the backend's configured
/// model id, already resolved per-project → global. That is the right default:
/// a local server almost always serves exactly one model, so every alias must
/// name it or the calls that use an alias (notably the background ones, which
/// use `haiku`) go to a model the server does not have.
///
/// `haiku_override` exists because that is the one alias someone might
/// legitimately want to point elsewhere — at a second, smaller server-side
/// model kept for cheap background work. A blank override falls back to
/// `effective_model` like the others.
///
/// Returns pairs in `(name, value)` form; a blank resolved value emits nothing
/// at all rather than an empty var, so an unconfigured backend is left exactly
/// as Claude Code found it.
pub fn compute_model_aliases(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> Vec<(&'static str, String)> {
let base = effective_model.map(str::trim).filter(|s| !s.is_empty());
let haiku = haiku_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or(base);
let mut out: Vec<(&'static str, String)> = Vec::new();
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_OPUS_MODEL, m.to_string()));
out.push((ANTHROPIC_DEFAULT_SONNET_MODEL, m.to_string()));
}
if let Some(h) = haiku {
out.push((ANTHROPIC_DEFAULT_HAIKU_MODEL, h.to_string()));
}
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_FABLE_MODEL, m.to_string()));
}
out
}
/// The fingerprint contribution of the model aliases, so that changing an
/// alias (or the model it falls back to) forces a container recreation.
/// `container_needs_recreation` is label-based and never diffs env, so an
/// env-only change is invisible without this.
fn model_alias_fingerprint_part(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> String {
compute_model_aliases(effective_model, haiku_override)
.into_iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",")
}
/// Whether `key` is an env var name Triple-C reserves for itself.
fn is_reserved_env_key(key: &str) -> bool {
let upper = key.to_uppercase();
@@ -360,7 +441,13 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
/// Includes the resolved base_url and model_id (per-project blank → global default).
/// Includes the resolved base_url and model_id (per-project blank → global
/// default) and the resolved model aliases.
///
/// NOTE: adding the alias part changes this hash for every existing Ollama
/// container, so each will be recreated once on the next start. That is exactly
/// what is wanted — recreation is the only way to get the new
/// `ANTHROPIC_DEFAULT_*_MODEL` vars into the container's env.
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config {
let effective_url = resolve_with_global(
@@ -371,7 +458,43 @@ fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSet
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![effective_url, effective_model];
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the llama.cpp configuration so we can detect
/// changes. Mirrors [`compute_ollama_fingerprint`].
fn compute_llamacpp_fingerprint(
project: &Project,
global_llamacpp: &GlobalLlamaCppSettings,
) -> String {
if let Some(ref cfg) = project.llamacpp_config {
let effective_url = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
@@ -393,10 +516,18 @@ fn compute_openai_compatible_fingerprint(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
),
);
let parts = vec![
effective_url,
config.api_key.as_deref().unwrap_or("").to_string(),
effective_model,
aliases,
];
sha256_hex(&parts.join("|"))
} else {
@@ -576,6 +707,7 @@ pub async fn create_container(
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
@@ -701,6 +833,14 @@ pub async fn create_container(
}
}
// ── Custom-endpoint backends ─────────────────────────────────────────────
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
// id here; the model-alias vars are emitted once below, from
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
let mut alias_model: Option<String> = None;
let mut alias_haiku: Option<String> = None;
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
@@ -716,7 +856,44 @@ pub async fn create_container(
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// llama.cpp (llama-server) configuration
if project.backend == Backend::LlamaCpp {
if let Some(ref cfg) = project.llamacpp_config {
if let Some(url) = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
// llama-server only enforces an Authorization header when it was
// started with `--api-key` (default: none), so the value here is
// ignored in the common case. Claude Code still refuses to run
// against a custom base URL with no credential at all, so a
// placeholder is always sent — same trick as the Ollama branch
// above, which sends the literal "ollama".
env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string());
if let Some(model) = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
@@ -737,7 +914,27 @@ pub async fn create_container(
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// Model aliases — the fix for background Claude Code calls against a local
// server. Only for backends that talk to a custom endpoint: Anthropic and
// Bedrock reach servers that really do host the Anthropic model ids, so
// they keep Claude Code's own defaults. Anything not emitted here is
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
// custom endpoint clears the aliases out of the snapshot image too.
if project.backend.uses_custom_endpoint() {
for (key, value) in
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
{
env_vars.push(format!("{}={}", key, value));
}
}
@@ -778,6 +975,15 @@ pub async fn create_container(
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
// *clear* the aliases, not merely stop setting them: a stale
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
// keep pointing background calls at a model id the new backend has
// never heard of.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
// Revoking the shared token, opting a project out, or switching away
// from the Anthropic backend must *clear* this, not merely stop setting
// it — otherwise the value committed into the snapshot image keeps
@@ -1004,6 +1210,7 @@ pub async fn create_container(
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.llamacpp-fingerprint".to_string(), compute_llamacpp_fingerprint(project, global_llamacpp));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
@@ -1301,6 +1508,7 @@ pub async fn container_needs_recreation(
project: &Project,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
@@ -1387,6 +1595,17 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── llama.cpp config fingerprint ─────────────────────────────────────
// A missing label means the container predates the llama.cpp backend, in
// which case the expected fingerprint is also "" (no llamacpp_config) and
// nothing is recreated needlessly.
let expected_llamacpp_fp = compute_llamacpp_fingerprint(project, global_llamacpp);
let container_llamacpp_fp = get_label("triple-c.llamacpp-fingerprint").unwrap_or_default();
if container_llamacpp_fp != expected_llamacpp_fp {
log::info!("llama.cpp config mismatch");
return Ok(true);
}
// ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
@@ -1635,3 +1854,230 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
Ok(siblings)
}
#[cfg(test)]
mod tests {
use super::*;
const OPUS: &str = ANTHROPIC_DEFAULT_OPUS_MODEL;
const SONNET: &str = ANTHROPIC_DEFAULT_SONNET_MODEL;
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
compute_model_aliases(model, haiku)
}
#[test]
fn all_four_aliases_fall_back_to_the_configured_model() {
assert_eq!(
aliases(Some("qwen3.5:27b"), None),
vec![
(OPUS, "qwen3.5:27b".to_string()),
(SONNET, "qwen3.5:27b".to_string()),
(HAIKU, "qwen3.5:27b".to_string()),
(FABLE, "qwen3.5:27b".to_string()),
]
);
}
#[test]
fn the_haiku_override_replaces_only_the_haiku_alias() {
let got = aliases(Some("big-model"), Some("small-model"));
assert_eq!(
got,
vec![
(OPUS, "big-model".to_string()),
(SONNET, "big-model".to_string()),
(HAIKU, "small-model".to_string()),
(FABLE, "big-model".to_string()),
]
);
}
#[test]
fn a_blank_or_whitespace_haiku_override_falls_back_to_the_model() {
for override_value in [Some(""), Some(" "), None] {
let got = aliases(Some("m"), override_value);
assert_eq!(
got.iter().find(|(k, _)| *k == HAIKU).map(|(_, v)| v.as_str()),
Some("m"),
"override {:?} should fall back to the model id",
override_value
);
}
}
#[test]
fn values_are_trimmed() {
assert_eq!(
aliases(Some(" m "), Some(" h ")),
vec![
(OPUS, "m".to_string()),
(SONNET, "m".to_string()),
(HAIKU, "h".to_string()),
(FABLE, "m".to_string()),
]
);
}
#[test]
fn no_model_and_no_override_emits_nothing() {
// Nothing to point the aliases at — leave Claude Code's defaults alone
// rather than injecting empty vars.
assert!(aliases(None, None).is_empty());
assert!(aliases(Some(""), Some(" ")).is_empty());
}
#[test]
fn a_haiku_override_alone_still_fixes_background_calls() {
// No model id configured, but the user pointed haiku somewhere: emit
// just that one, because it is the alias background work uses.
assert_eq!(
aliases(None, Some("small-model")),
vec![(HAIKU, "small-model".to_string())]
);
}
#[test]
fn only_custom_endpoint_backends_get_aliases() {
assert!(!Backend::Anthropic.uses_custom_endpoint());
assert!(!Backend::Bedrock.uses_custom_endpoint());
assert!(Backend::Ollama.uses_custom_endpoint());
assert!(Backend::LlamaCpp.uses_custom_endpoint());
assert!(Backend::OpenAiCompatible.uses_custom_endpoint());
}
#[test]
fn every_alias_var_is_reserved_and_managed() {
for key in [OPUS, SONNET, HAIKU, FABLE] {
assert!(is_reserved_env_key(key), "{} must be reserved", key);
assert!(
is_reserved_env_key(&key.to_lowercase()),
"{} must be reserved case-insensitively",
key
);
}
// A user-set alias must never survive into the container env.
let fp = compute_env_fingerprint(&[EnvVar {
key: HAIKU.to_string(),
value: "sneaky".to_string(),
}]);
assert_eq!(fp, "");
}
#[test]
fn the_deprecated_small_fast_model_var_is_never_emitted() {
let rendered: Vec<String> = aliases(Some("m"), Some("h"))
.into_iter()
.map(|(k, _)| k.to_string())
.collect();
assert!(!rendered.iter().any(|k| k == "ANTHROPIC_SMALL_FAST_MODEL"));
}
#[test]
fn the_alias_fingerprint_tracks_both_the_model_and_the_override() {
let base = model_alias_fingerprint_part(Some("m"), None);
assert_eq!(base, model_alias_fingerprint_part(Some("m"), Some("")));
assert_ne!(base, model_alias_fingerprint_part(Some("m2"), None));
assert_ne!(base, model_alias_fingerprint_part(Some("m"), Some("h")));
assert_eq!(model_alias_fingerprint_part(None, None), "");
}
fn project_with_llamacpp(model: Option<&str>, haiku: Option<&str>) -> Project {
let mut p = Project::new("t".to_string(), Vec::new());
p.backend = Backend::LlamaCpp;
p.llamacpp_config = Some(crate::models::LlamaCppConfig {
base_url: "http://host.docker.internal:8080".to_string(),
model_id: model.map(str::to_string),
haiku_model_id: haiku.map(str::to_string),
});
p
}
#[test]
fn llamacpp_fingerprint_changes_when_the_haiku_override_changes() {
let g = GlobalLlamaCppSettings::default();
let a = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), None), &g);
let b = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), Some("h")), &g);
assert_ne!(a, b, "the haiku override must force a container recreation");
// No config at all -> empty, so projects on other backends are not
// flagged for recreation by this fingerprint.
let plain = Project::new("t".to_string(), Vec::new());
assert_eq!(compute_llamacpp_fingerprint(&plain, &g), "");
}
#[test]
fn llamacpp_global_defaults_fill_in_for_blank_per_project_fields() {
let g = GlobalLlamaCppSettings {
base_url: Some("http://elsewhere:8080".to_string()),
default_model_id: Some("global-model".to_string()),
default_haiku_model_id: Some("global-haiku".to_string()),
};
// The per-project base URL is set in the fixture, so only the model and
// haiku fields fall through to the globals. Filling them in from the
// globals must be indistinguishable from setting them per-project.
let with_global = compute_llamacpp_fingerprint(&project_with_llamacpp(None, None), &g);
let explicit = compute_llamacpp_fingerprint(
&project_with_llamacpp(Some("global-model"), Some("global-haiku")),
&GlobalLlamaCppSettings::default(),
);
assert_eq!(with_global, explicit);
// …and changing a global must change the fingerprint, so a global-only
// edit still forces a recreation.
assert_ne!(
with_global,
compute_llamacpp_fingerprint(
&project_with_llamacpp(None, None),
&GlobalLlamaCppSettings {
default_haiku_model_id: Some("other-haiku".to_string()),
..g.clone()
},
)
);
assert_eq!(
resolve_with_global(None, g.default_haiku_model_id.as_deref()),
Some("global-haiku")
);
assert_eq!(
resolve_with_global(Some(" "), g.default_model_id.as_deref()),
Some("global-model")
);
}
#[test]
fn backend_serde_round_trips_llamacpp_and_accepts_legacy_spellings() {
assert_eq!(
serde_json::to_string(&Backend::LlamaCpp).unwrap(),
"\"llama_cpp\""
);
for spelling in ["\"llama_cpp\"", "\"llamacpp\"", "\"llama-cpp\"", "\"llama.cpp\""] {
let parsed: Backend = serde_json::from_str(spelling).unwrap();
assert_eq!(parsed, Backend::LlamaCpp, "failed for {}", spelling);
}
}
#[test]
fn a_project_json_without_llamacpp_config_still_deserialises() {
// `projects.json` written by an older build has no llamacpp_config key.
let json = serde_json::json!({
"id": "p1",
"name": "old",
"paths": [],
"container_id": null,
"status": "stopped",
"backend": "ollama",
"bedrock_config": null,
"ollama_config": { "base_url": "http://x:11434", "model_id": "m" },
"openai_compatible_config": null,
"allow_docker_access": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
});
let p: Project = serde_json::from_value(json).unwrap();
assert!(p.llamacpp_config.is_none());
// The new per-backend haiku override also defaults cleanly.
assert!(p.ollama_config.unwrap().haiku_model_id.is_none());
}
}
+644
View File
@@ -0,0 +1,644 @@
//! 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 port is published on `0.0.0.0`, not `127.0.0.1`.** STT is consumed
//! by the Tauri host process, so loopback is enough. The gateway is consumed
//! by *project containers*, which sit on Docker's default bridge and reach
//! the host through the bridge gateway — a loopback-only bind is invisible to
//! them. See [`gateway_base_url`].
//! * **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 futures_util::StreamExt;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::Write;
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 value a project should use as its base URL (`ANTHROPIC_BASE_URL`).
///
/// 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. Publishing the gateway on `0.0.0.0:<port>`
/// makes it reachable from every container network on the machine:
///
/// * Docker Desktop (macOS / Windows / WSL2) resolves `host.docker.internal`
/// from inside containers automatically — that is the portable value and the
/// one already suggested by the existing OpenAI-compatible placeholder text.
/// * On native Linux Docker `host.docker.internal` is not injected, and the
/// equivalent address is the default bridge gateway, normally
/// `http://172.17.0.1:<port>`.
pub fn gateway_base_url(port: u16) -> String {
format!("http://host.docker.internal:{}", port)
}
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_base_url(settings.port),
})
}
/// `(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))?;
if let Some(container) = containers.first() {
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) -> String {
let models: Vec<String> = settings
.valid_models()
.iter()
.map(|m| format!("{}={}", m.name.trim(), m.model_id.trim()))
.collect();
format!(
"provider={};api_base={};port={};models={}",
settings.provider.trim(),
settings.api_base.as_deref().unwrap_or("").trim(),
settings.port,
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,
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 {
// Not loopback — project containers reach this through the host.
// See `gateway_base_url`.
host_ip: Some("0.0.0.0".to_string()),
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.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)
}
pub async fn ensure_gateway_running(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()?;
// 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),
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, &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 port {} ({} model(s))",
settings.port,
settings.valid_models().len()
);
get_gateway_status(settings).await
}
pub async fn stop_gateway_container() -> Result<(), String> {
let docker = get_docker()?;
if let Some((id, state, _)) = find_gateway_container().await? {
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.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))?;
match client
.get(format!("http://127.0.0.1:{}/health/liveliness", port))
.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 a = config_shape(&settings());
let mut s = settings();
s.models[0].model_id = "gpt-4.1".to_string();
assert_ne!(a, config_shape(&s));
assert!(!a.contains("sk-"));
}
#[test]
fn base_url_points_at_the_host_not_loopback() {
// A project container cannot reach the host's loopback interface.
let url = gateway_base_url(4000);
assert_eq!(url, "http://host.docker.internal:4000");
assert!(!url.contains("127.0.0.1"));
}
}
+3
View File
@@ -2,9 +2,12 @@ pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub mod gateway;
pub mod legacy_cleanup;
pub mod stt;
#[allow(unused_imports)]
pub use gateway::*;
#[allow(unused_imports)]
pub use stt::*;
#[allow(unused_imports)]
+39
View File
@@ -1,4 +1,5 @@
mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
mod install_helper;
@@ -126,6 +127,25 @@ pub fn run() {
});
}
// Auto-start model gateway container if enabled in settings
if settings.gateway.enabled {
let gateway_settings = settings.gateway.clone();
tauri::async_runtime::spawn(async move {
match docker::gateway::ensure_gateway_running(&gateway_settings).await {
Ok(status) => {
if status.running {
log::info!("Model gateway auto-started on port {}", gateway_settings.port);
} else {
log::warn!("Model gateway auto-start: container not running after ensure_gateway_running");
}
}
Err(e) => {
log::error!("Failed to auto-start model gateway container: {}", e);
}
}
});
}
Ok(())
})
.on_window_event(|window, event| {
@@ -139,10 +159,14 @@ pub fn run() {
}
// Stop STT container
let _ = docker::stt::stop_stt_container().await;
// Stop model gateway container
let _ = docker::gateway::stop_gateway_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;
// Stop any browser-view proxies and in-container dashboards
browser_view::manager().stop_all().await;
});
}
})
@@ -165,6 +189,10 @@ pub fn run() {
// 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 +244,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,
+26
View File
@@ -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,
}
+2
View File
@@ -1,9 +1,11 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use update_info::*;
+78 -7
View File
@@ -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,
+113
View File
@@ -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">&#8595;</button>
<!-- URL relay: a CLI in the container asked for a browser. Tap-to-open only,
never automatic — see the OSC 7777 handler below. -->
<div class="relay-banner" id="relayBanner">
<div class="relay-banner-text">
<div class="relay-banner-label">Container asked to open a URL &mdash; tap to open here</div>
<a class="relay-banner-url" id="relayBannerLink" target="_blank" rel="noopener noreferrer"></a>
</div>
<button class="relay-banner-dismiss" id="relayBannerDismiss" aria-label="Dismiss">&#10005;</button>
</div>
</div>
<!-- Input Bar for mobile/tablet -->
@@ -309,6 +363,96 @@
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;
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:.
if (/[\s\u0000-\u0020\u007f]/.test(s)) 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();
}
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 +592,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';
+1 -1
View File
@@ -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": {
@@ -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 isnt 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(/didnt 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 isnt 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 didnt 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 Playwrights live dashboard inside this container
and mirror it here. Youll see any browser a script has published with{" "}
<Code>await browser.bind(&apos;claude&apos;)</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 cant 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>
);
}
@@ -21,6 +21,7 @@ const BACKEND_LABEL: Record<Project["backend"], string> = {
anthropic: "Anthropic",
bedrock: "AWS Bedrock",
ollama: "Ollama",
llama_cpp: "llama.cpp",
open_ai_compatible: "OpenAI Compatible",
};
@@ -14,6 +14,7 @@ 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 +23,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"];
@@ -206,6 +208,9 @@ 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>
{confirmReset && (
@@ -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,482 @@
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]);
const patch = async (changes: Partial<GatewaySettingsType>) => {
if (!appSettings) return;
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
};
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";
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 })}
/>
}
/>
{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>
)}
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{/* ── 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"}/&lt;model id&gt;</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. On native Linux Docker, where{" "}
<code className="font-mono">host.docker.internal</code> is not injected into
containers, use <code className="font-mono">http://172.17.0.1:{gateway.port}</code>{" "}
instead.
</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>
);
}
+16 -1
View File
@@ -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}>
+52 -12
View File
@@ -10,6 +10,11 @@ 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,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
@@ -37,7 +42,13 @@ 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.
const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string } | null>(
null,
);
const relayLimiterRef = useRef(new RelayRateLimiter());
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -212,6 +223,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;
}
setUrlPrompt({ url, label: "Container asked to open a URL" });
return true;
});
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
@@ -295,7 +331,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) =>
setUrlPrompt({ url, label: "Long URL detected" }),
);
detectorRef.current = detector;
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
@@ -369,6 +407,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 +464,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 +477,13 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
if (urlPrompt) {
openUrl(urlPrompt.url).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
setUrlPrompt(null);
}
}, [detectedUrl]);
}, [urlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
@@ -516,11 +555,12 @@ export default function TerminalView({ sessionId, active }: Props) {
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
{urlPrompt && (
<UrlToast
url={detectedUrl}
url={urlPrompt.url}
label={urlPrompt.label}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
onDismiss={() => setUrlPrompt(null)}
/>
)}
{imagePasteMsg && (
+9 -2
View File
@@ -1,10 +1,17 @@
interface Props {
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) {
export default function UrlToast({
url,
label = "Long URL detected",
onOpen,
onDismiss,
}: Props) {
return (
<div
className="animate-slide-down"
@@ -33,7 +40,7 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
marginBottom: 2,
}}
>
Long URL detected
{label}
</div>
<div
style={{
+29 -1
View File
@@ -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 } 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.
+114 -1
View File
@@ -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,6 +426,44 @@ 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. */
export interface ClaudeTokenProgressEvent {
+241
View File
@@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
MAX_RELAY_URL_LENGTH,
RelayRateLimiter,
URL_RELAY_OSC,
parseUrlRelayOsc,
sanitizeRelayUrl,
} 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("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);
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* URL relay host side of `container/triple-c-open`.
*
* 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.
*/
/** 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;
/**
* Validate a URL the container asked the host to open.
*
* @returns the normalized URL, or `null` if it must not be opened.
*/
export function sanitizeRelayUrl(raw: unknown): 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;
// No whitespace or control characters anywhere. Rejecting these before
// parsing matters: `new URL()` silently strips tabs/newlines, so
// "java\nscript:alert(1)" would otherwise parse as a javascript: URL.
// eslint-disable-next-line no-control-regex
if (/[\s\u0000-\u0020\u007f]/.test(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.
if (parsed.username !== "" || parsed.password !== "") return null;
const normalized = parsed.toString();
if (normalized.length > MAX_RELAY_URL_LENGTH) return null;
return normalized;
}
/**
* 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;
}
}
+51
View File
@@ -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
+13 -1
View File
@@ -242,6 +242,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 +267,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"
+136
View File
@@ -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"
+56
View File
@@ -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"]
+27
View File
@@ -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