diff --git a/CLAUDE.md b/CLAUDE.md index f6a569b..1c3dea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,12 +97,31 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker API via `socat`. Opt-in per project. +- **`browser_view/`** — Watch and take over the browser Claude drives with Playwright inside the + container. Runs Playwright's own dashboard (`browser.bind()` + `playwright-cli show`) in the + container and fronts it with a **token-gated** loopback proxy. Deliberately does **not** reuse + the auth bridge's `PortForward`, which binds an unauthenticated port — fine for a throwaway + OAuth listener, wrong for remote control of a browser. Host ports are confined to + `47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them; + a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project. - **`docker/`** — Docker API layer using bollard: - `client.rs` — Singleton Docker connection via `OnceLock` - `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an attached exec is opened; terminal sessions and the auth bridge both go through it. - `image.rs` — Image build/pull with progress streaming + - `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format + front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`. + Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume + it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach + it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`). + `GatewayBinding` derives the bind address and the advertised `base_url` together so they + cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls — + in front of a container config holding a billed provider key. It also **always** sets a + LiteLLM `master_key`, since LiteLLM without one accepts any key. + - `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure + delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the + crash-recovery state machine. See "Base-image migration" below. - `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once users have migrated. @@ -110,7 +129,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect - `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()` -- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend. +- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LlamaCppConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend. - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs` ### Container (`container/`) @@ -119,6 +138,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations +**`/home/claude` in the image is seed-only.** It is the mount point of the named volume +`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory +is masked permanently and can never be updated again. A change you make under `/home/claude` in +the `Dockerfile` or in `entrypoint.sh`'s "copy this into the home dir" style reaches **new +projects only** — existing ones will never see it, with or without a base-image migration. + +So: **anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or must be seeded +by `entrypoint.sh` at runtime** (i.e. written on every start, from a source outside the home +volume, the way `CLAUDE_INSTRUCTIONS` → `~/.claude/CLAUDE.md` and the Mission Control skill copy +already are). Putting it in the image's `/home/claude` and expecting an image update to deliver it +is the mistake. + +The flip side is the useful half of the same fact: Claude Code itself (`~/.local/bin`), cargo, uv, +ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks and SSH keys all +re-attach for free when a container is recreated from a *different* image — which is what makes +base-image migration cheap. + ### Container Lifecycle Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. @@ -129,13 +165,72 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p intentional (Reset exists to get back to a clean base image), but do not describe Reset as preserving credentials. +### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`) + +A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and +every recreation re-commits it — so without an explicit act, a project stays on the base image it +was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a +security update. Migration is the non-destructive way out; Reset is the destructive one. + +- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records + the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long + comment there. Comparing it would recreate every project *from its own snapshot* on the next base + bump: churn on the old base, and it would consume the "you should migrate" signal without + migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it. +- **A missing lineage label means "unknown, probe instead", never "stale".** +- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every + crash before that point self-heal — `start_project_container` just recreates from the old + snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress` + label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback. +- **Rollback restores the system layer only.** The volumes are never touched at any point, so work + done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy. +- **`/var` is never copied either, and that is the one way migration is *more* destructive than + the ordinary recreate.** A recreate builds from the project's snapshot, so `/var/lib/postgresql` + rides along; a migration builds from the base and the apt replay hands back an empty cluster. + Copying a live database's files onto a different base's version of the same package is a + corruption risk, not a fix — so the answer is disclosure. `unpreserved_data()` reports + first-level directories under `/var/lib` and `/var/www` that the base does not ship *and* that + hold non-dpkg-owned files (which is what keeps `/var/lib/apt` and `/var/lib/dpkg` out of it), + and the pre-flight, the banner and the finished report all name them. Do not make this silent. +- **The rollback pin is not best-effort.** After `commit_container_snapshot` the commit is the only + copy of the old system layer, so a `docker tag` that fails — or succeeds without the reference + resolving — aborts the migration before `remove_container`. Same rule in reverse for + `rollback_migration`: the image is confirmed to exist before the container is destroyed. +- **`resume` must check the container's `triple-c.migration-state` label**, exactly as + `reconcile_migration` does. Without it a record left behind by a failed commit "resumes" into + the *old, unmigrated* container and commits it as migrated. +- **Anything that stops, removes or recreates a project's container consults + `migration_commands::is_migrating`.** The window between `remove_container` and the create that + follows looks exactly like "no container" to Start, and Reset would delete the volumes out from + under a live run. +- **`/etc` is never copied**, only reported: the snapshot lineage has + `/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and + having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical. +- **`docker diff` is useless here** — on a snapshot-derived container it reports only changes since + the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg + ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path + differences into 2 genuinely user-authored ones. + ### Authentication Per-project, independently configured: - **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume - **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars - **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`) -- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` +- **llama.cpp** — Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080`, its default port) +- **OpenAI Compatible** — Connect through a gateway implementing the **Anthropic Messages API** (LiteLLM) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` + +**Claude Code only ever speaks the Anthropic Messages API** (`POST /v1/messages?beta=true`) to +`ANTHROPIC_BASE_URL` — never OpenAI's `/v1/chat/completions`. Ollama and llama.cpp implement +`/v1/messages` natively, which is why each gets a plain base-URL backend with no translation shim. +A server that only exposes an OpenAI-shaped API does not work behind any backend. + +For every backend pointing at a custom endpoint (`Backend::uses_custom_endpoint`), all four +`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` vars are pinned to the backend's configured +model id, with an optional per-backend Haiku override. Without this, Claude Code's background +calls resolve `haiku` to an Anthropic model id the local server does not have and fail silently. +Anthropic and Bedrock deliberately keep Claude Code's own defaults. +`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and must not be used. ## Styling @@ -157,6 +252,16 @@ Per-project, independently configured: environment or configuration, you must also write a corresponding `triple-c.*` label at creation and compare it there, or the change will silently not take effect until some unrelated setting forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`. + (`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the + reasoning is in the comment beside the check.) +- **Always write a `triple-c.*` label explicitly, even when the value is empty.** Docker merges an + image's labels into a container's at creation, and `docker commit` copies container labels onto + the snapshot image — so a label stamped once rides that snapshot into *every* future container + forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not + been written by any code since the MCP feature was removed, yet a snapshot image was found still + carrying a non-empty one, which made its one-shot recreation shim recreate that project on every + single start. Writing the key explicitly overrides the inherited value — the same defence + `MANAGED_AUTH_KEYS` applies to env vars. - **New model fields need an explicit serde default when the correct default isn't the zero value.** `#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in `models/project.rs` for anything that should default to true. 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/Cargo.toml b/app/src-tauri/Cargo.toml index f4a2031..52cff6f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -38,6 +38,11 @@ base64 = "0.22" rand = "0.9" local-ip-address = "0.6" +[dev-dependencies] +# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run +# their backoff schedule under a paused clock instead of in real seconds. +tokio = { version = "1", features = ["full", "test-util"] } + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 568e925..3f89ba3 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -56,7 +56,7 @@ use tokio::sync::{watch, Mutex}; use tokio::task::JoinHandle; use crate::docker::container::is_container_running; -use crate::docker::exec::exec_oneshot; +use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT}; use crate::storage::projects_store::ProjectsStore; use proc_net::PortFamily; @@ -248,9 +248,14 @@ impl AuthBridgeManager { /// project whose bridge is on but whose container is stopped still reports /// `enabled: true` with no active ports. pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus { - let map = self.bridges.lock().await; - match map.get(project_id) { - Some(bridge) => bridge.state.lock().await.snapshot(enabled), + // Clone the per-project handle out and drop the map lock before taking + // the state lock. Holding both across the nested await is not a + // deadlock — the order is consistently bridges→state — but it puts a + // cheap UI status call behind whatever the poller is doing under + // `state`, and behind every other project's status call too. + let state = self.bridges.lock().await.get(project_id).map(|b| b.state.clone()); + match state { + Some(state) => state.lock().await.snapshot(enabled), None => AuthBridgeStatus { enabled, ..AuthBridgeStatus::disabled() @@ -300,8 +305,16 @@ async fn poll_loop( } // One exec per tick reads both procfs files. + // + // Absolute path, deliberately: the image's `ENV PATH` puts a + // container-writable directory first, so a bare `cat` is a name the + // container can rebind to a shim that prints whatever it likes. It + // still could not make us bind a *non-loopback* port, but it decides + // how much output this loop ingests and how many host ports it is asked + // for, which is why the call is also length-capped and the result + // count is capped in `reconcile`. let cmd = vec![ - "cat".to_string(), + "/usr/bin/cat".to_string(), "/proc/net/tcp".to_string(), "/proc/net/tcp6".to_string(), ]; @@ -309,7 +322,7 @@ async fn poll_loop( // bridge or stopping the container doesn't wait out an in-flight poll. let discovery = tokio::select! { _ = cancel.changed() => break, - res = exec_oneshot(&container_id, cmd) => res, + res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res, }; match discovery { @@ -362,14 +375,71 @@ async fn poll_loop( /// Ports Docker already handles for this project. A container port that is /// explicitly published has a host-side path already, and the mapping's host /// port is a binding we must not fight over. +/// +/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container +/// loopback listeners another feature owns and exposes on its own, +/// authenticated terms. fn skipped_ports(project: &crate::models::Project) -> HashSet { - 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.extend(RESERVED_HOST_PORTS.clone()); + skip } +// ───────────────────────────────────────────────────────────────────────────── +// Reservations +// ───────────────────────────────────────────────────────────────────────────── + +/// Container loopback ports another feature owns, which the bridge must leave +/// alone. +/// +/// The bridge's contract is "mirror every container loopback listener onto the +/// same host port, **unauthenticated**" — correct for the throwaway OAuth +/// callback listeners it exists for, wrong for anything sensitive. The +/// browser-view pane runs Playwright's dashboard on a container loopback port +/// in this range and puts a token-gated listener in front of it; mirroring that +/// port here would quietly publish an ungated second door to full control of a +/// browser inside the container. +/// +/// This is a constant rather than a registry the pane populates at runtime, and +/// that is the point: Playwright's dashboard is a detached daemon that outlives +/// the app, so after a crash an orphaned viewer can still be listening with +/// nothing in this process left to remember it. A static range is the only form +/// of the rule that survives a restart. It must stay in step with +/// `browser_view::VIEWER_PORTS`, which asserts on it. +pub const RESERVED_CONTAINER_PORTS: std::ops::RangeInclusive = 39321..=39328; + +/// Host ports another feature binds on demand, which the bridge must not take +/// first. +/// +/// These are the browser-view proxy's host ports. The bridge binds *host* ports +/// named by the container, so a container listening on 47820 would have the +/// bridge take the host side of that number — and then the browser-view pane, +/// which only binds when the user opens it, finds its port gone. The two ranges +/// are separate constants because they guard opposite ends of the same +/// mechanism: [`RESERVED_CONTAINER_PORTS`] is about not *publishing* something, +/// this one is about not *stealing* something. +pub const RESERVED_HOST_PORTS: std::ops::RangeInclusive = + crate::browser_view::proxy::PROXY_PORTS; + +/// Most host ports the bridge will hold for one project at a time. +/// +/// The discovery input is entirely container-controlled, and each +/// [`PortForward`] costs two listeners plus a task, so without a cap a +/// container that reports tens of thousands of fake listeners exhausts the +/// app's file descriptors and the host's ephemeral ports in a single tick. A +/// real login flow uses one or two ports at a time; anything past a couple of +/// dozen is not a login. +const MAX_FORWARDS: usize = 24; + +/// Most conflicts recorded at once, so a flood of unbindable ports can't grow +/// the status payload (and the UI list) without bound either. +const MAX_CONFLICTS: usize = 32; + /// Bring the set of host listeners in line with what the container is currently /// listening on. Returns whether anything the UI cares about changed. async fn reconcile( @@ -412,6 +482,20 @@ async fn reconcile( if skip.contains(&port) || st.forwards.contains_key(&port) { continue; } + if st.forwards.len() >= MAX_FORWARDS { + // Don't even attempt the bind: the point of the cap is to stop the + // container dictating how many host resources we take. + changed |= note_conflict( + &mut st, + port, + format!( + "The auth bridge is already holding {} ports for this project; \ + {} was not bridged.", + MAX_FORWARDS, port + ), + ); + continue; + } match PortForward::bind(container_id.to_string(), port, family).await { Ok(forward) => { if st.conflicts.remove(&port).is_some() { @@ -438,9 +522,8 @@ async fn reconcile( ); if st.conflicts.get(&port) != Some(&reason) { log::warn!("Auth bridge: {}", reason); - st.conflicts.insert(port, reason); - changed = true; } + changed |= note_conflict(&mut st, port, reason); } } } @@ -448,6 +531,23 @@ async fn reconcile( changed } +/// Record why a port wasn't bridged, up to [`MAX_CONFLICTS`]. Returns whether +/// the recorded set changed. +fn note_conflict(state: &mut BridgeState, port: u16, reason: String) -> bool { + match state.conflicts.get(&port) { + Some(existing) if *existing == reason => false, + Some(_) => { + state.conflicts.insert(port, reason); + true + } + None if state.conflicts.len() < MAX_CONFLICTS => { + state.conflicts.insert(port, reason); + true + } + None => false, + } +} + /// Release every host port held for this project. Awaits each shutdown, so on /// return nothing is bound. async fn teardown(project_id: &str, state: &Arc>) { @@ -519,7 +619,84 @@ mod tests { } #[test] - fn no_mappings_means_nothing_is_skipped() { - assert!(skipped_ports(&project_with_mappings(vec![])).is_empty()); + fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() { + let skip = skipped_ports(&project_with_mappings(vec![])); + assert_eq!( + skip.len(), + RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count() + ); + } + + #[test] + fn the_browser_views_host_ports_are_never_taken() { + // The bridge binds *host* ports chosen by the container, so without + // this it can take the port the browser-view proxy will want later — + // that pane binds on demand, so first-come would win. + let skip = skipped_ports(&project_with_mappings(vec![])); + for port in RESERVED_HOST_PORTS { + assert!(skip.contains(&port), "host port {} should be reserved", port); + } + assert!(!skip.contains(&(RESERVED_HOST_PORTS.end() + 1))); + } + + #[test] + fn conflicts_stop_being_recorded_past_the_cap() { + let mut st = BridgeState::default(); + for port in 1000u16..1000 + MAX_CONFLICTS as u16 { + assert!(note_conflict(&mut st, port, "busy".to_string())); + } + // Past the cap: new ports are dropped rather than growing the status + // payload the UI renders. + assert!(!note_conflict(&mut st, 9999, "busy".to_string())); + assert_eq!(st.conflicts.len(), MAX_CONFLICTS); + // A changed reason for a port already tracked still updates. + assert!(!note_conflict(&mut st, 1000, "busy".to_string())); + assert!(note_conflict(&mut st, 1000, "different".to_string())); + assert_eq!(st.conflicts.len(), MAX_CONFLICTS); + } + + #[tokio::test] + async fn the_host_ports_one_container_can_demand_are_capped() { + // The container fully controls the discovery input (it can shim the + // probe command), and each forward costs two listeners plus a task — + // uncapped, one tick could exhaust the app's fds and the host's + // ephemeral ports. + let discovered: BTreeMap = + (45000u16..45200).map(|p| (p, PortFamily::V4)).collect(); + let state = Arc::new(Mutex::new(BridgeState::default())); + + reconcile("no-such-container", &discovered, &HashSet::new(), &state).await; + + let mut st = state.lock().await; + assert!( + st.forwards.len() <= MAX_FORWARDS, + "bridged {} ports, cap is {}", + st.forwards.len(), + MAX_FORWARDS + ); + assert!(st.conflicts.len() <= MAX_CONFLICTS); + // Nowhere near the 200 the "container" asked for. + assert!(st.forwards.len() + st.conflicts.len() < discovered.len()); + + for (_, mut forward) in std::mem::take(&mut st.forwards) { + forward.shutdown().await; + } + } + + #[test] + fn the_browser_views_ports_are_never_mirrored() { + // Mirroring these would publish an ungated second door to the + // Playwright dashboard, which the pane deliberately keeps behind a + // token-checking listener. + let skip = skipped_ports(&project_with_mappings(vec![])); + for port in RESERVED_CONTAINER_PORTS { + assert!(skip.contains(&port), "port {} should be reserved", port); + } + assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1))); + + // Reservations coexist with Docker's own published ports. + let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)])); + assert!(skip.contains(RESERVED_CONTAINER_PORTS.start())); + assert!(skip.contains(&3000)); } } 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