From cc5f691677dd6ddea465a4c70e370e512e91da36 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 16:55:28 -0700 Subject: [PATCH 1/9] Add llama.cpp backend, model gateway, URL relay and browser view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 29 +- HOW-TO-USE.md | 231 ++++- README.md | 97 +- TECHNICAL.md | 20 +- app/src-tauri/src/auth_bridge/mod.rs | 55 +- app/src-tauri/src/auth_bridge/tunnel.rs | 25 + app/src-tauri/src/browser_view/commands.rs | 78 ++ app/src-tauri/src/browser_view/detect.rs | 263 ++++++ app/src-tauri/src/browser_view/mod.rs | 839 ++++++++++++++++++ app/src-tauri/src/browser_view/proxy.rs | 699 +++++++++++++++ .../src/commands/gateway_commands.rs | 88 ++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_commands.rs | 13 + app/src-tauri/src/docker/container.rs | 452 +++++++++- app/src-tauri/src/docker/gateway.rs | 644 ++++++++++++++ app/src-tauri/src/docker/mod.rs | 3 + app/src-tauri/src/lib.rs | 39 + app/src-tauri/src/models/app_settings.rs | 26 + app/src-tauri/src/models/gateway_settings.rs | 99 +++ app/src-tauri/src/models/mod.rs | 2 + app/src-tauri/src/models/project.rs | 85 +- app/src-tauri/src/storage/secure.rs | 113 +++ app/src-tauri/src/web_terminal/terminal.html | 153 ++++ app/src-tauri/tauri.conf.json | 2 +- .../projects/home/BrowserTab.test.tsx | 178 ++++ .../components/projects/home/BrowserTab.tsx | 261 ++++++ .../components/projects/home/OverviewTab.tsx | 1 + .../components/projects/home/ProjectHome.tsx | 5 + .../home/config/ModelSection.test.tsx | 124 ++- .../projects/home/config/ModelSection.tsx | 135 ++- .../components/settings/GatewaySettings.tsx | 482 ++++++++++ .../components/settings/LlamaCppSettings.tsx | 68 ++ .../components/settings/OllamaSettings.tsx | 17 +- .../settings/OpenAiCompatibleSettings.tsx | 22 +- app/src/components/settings/SettingsPanel.tsx | 6 + app/src/components/terminal/TerminalView.tsx | 64 +- app/src/components/terminal/UrlToast.tsx | 11 +- app/src/lib/tauri-commands.ts | 30 +- app/src/lib/types.ts | 115 ++- app/src/lib/urlRelay.test.ts | 241 +++++ app/src/lib/urlRelay.ts | 155 ++++ container/Dockerfile | 51 ++ container/entrypoint.sh | 14 +- container/triple-c-open | 136 +++ gateway-container/Dockerfile | 56 ++ gateway-container/config.yaml | 27 + 46 files changed, 6194 insertions(+), 61 deletions(-) create mode 100644 app/src-tauri/src/browser_view/commands.rs create mode 100644 app/src-tauri/src/browser_view/detect.rs create mode 100644 app/src-tauri/src/browser_view/mod.rs create mode 100644 app/src-tauri/src/browser_view/proxy.rs create mode 100644 app/src-tauri/src/commands/gateway_commands.rs create mode 100644 app/src-tauri/src/docker/gateway.rs create mode 100644 app/src-tauri/src/models/gateway_settings.rs create mode 100644 app/src/components/projects/home/BrowserTab.test.tsx create mode 100644 app/src/components/projects/home/BrowserTab.tsx create mode 100644 app/src/components/settings/GatewaySettings.tsx create mode 100644 app/src/components/settings/LlamaCppSettings.tsx create mode 100644 app/src/lib/urlRelay.test.ts create mode 100644 app/src/lib/urlRelay.ts create mode 100755 container/triple-c-open create mode 100644 gateway-container/Dockerfile create mode 100644 gateway-container/config.yaml diff --git a/CLAUDE.md b/CLAUDE.md index f6a569b..203509d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 566e372..b6a3fc2 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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 ` in any script | Opening a page directly | +| `python3 -m webbrowser ` | 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 ` 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 ` 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 diff --git a/README.md b/README.md index b822ba0..5208d32 100644 --- a/README.md +++ b/README.md @@ -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 ; 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) diff --git a/TECHNICAL.md b/TECHNICAL.md index d6269da..58d657c 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -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 diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 568e925..8739157 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -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 { - project + let mut skip: HashSet = 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 = 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)); } } diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index 216c207..0ee6ecf 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -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, +) { 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 { diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs new file mode 100644 index 0000000..b9f5efb --- /dev/null +++ b/app/src-tauri/src/browser_view/commands.rs @@ -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 { + 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 { + 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 { + 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 +} diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs new file mode 100644 index 0000000..154f345 --- /dev/null +++ b/app/src-tauri/src/browser_view/detect.rs @@ -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, + /// Resolved `playwright-core` (or `playwright`) version. + #[serde(default)] + pub playwright_version: Option, + /// Absolute path of the resolved package manifest, for the diagnostics line. + #[serde(default)] + pub playwright_path: Option, + /// 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, + /// 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, + /// Where the probe looked, echoed back for the "not found" message. + #[serde(default)] + pub searched: Vec, +} + +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 { + 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 { + 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 { + 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)); + } +} diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs new file mode 100644 index 0000000..b77c618 --- /dev/null +++ b/app/src-tauri/src/browser_view/mod.rs @@ -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

` 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