Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Triple-C (Claude-Code-Container) is a Tauri v2 desktop application that sandboxes Claude Code inside Docker containers. It has two main parts: a React/TypeScript frontend, a Rust backend, and a Docker container image definition.
Build & Development Commands
All frontend/tauri commands run from the app/ directory:
cd app
npm ci # Install dependencies (required first time)
npx tauri dev # Launch app in dev mode with hot reload (Vite on port 1420)
npx tauri build # Production build (outputs to src-tauri/target/release/bundle/)
npm run build # Frontend-only build (tsc + vite)
npm run test # Run Vitest once
npm run test:watch # Run Vitest in watch mode
Rust backend is compiled automatically by tauri dev/tauri build. To check Rust independently:
cd app/src-tauri
cargo check # Type-check without full build
cargo build # Build Rust backend only
Container image:
docker build -t triple-c-sandbox ./container
Linux Build Dependencies (Ubuntu/Debian)
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf libssl-dev pkg-config build-essential
Architecture
Two-Process Model (Tauri IPC)
- React frontend (
app/src/) renders UI in the OS webview - Rust backend (
app/src-tauri/src/) handles Docker API, credential storage, and terminal I/O - Communication uses two patterns:
invoke()— request/response for discrete operations (CRUD, start/stop containers)emit()/listen()— event streaming for continuous data (terminal I/O)
Terminal I/O Flow
User keystroke → xterm.js onData() → invoke("terminal_input") → mpsc channel → docker exec stdin
docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → listen() → xterm.js write()
Frontend Structure (app/src/)
store/appState.ts— Single Zustand store for all app state (projects, sessions, UI). The main area is a single ordered tab strip holding two tab kinds, keyedterm:<id>andhome:<id>;activeSessionIdis derived fromactiveTabKeyso exactly one thing is current.hooks/— All Tauri IPC calls are encapsulated in hooks (useTerminal,useProjects,useDocker,useSettings)lib/tauri-commands.ts— Typedinvoke()wrappers; TypeScript types inlib/types.tsmust match Rust modelscomponents/terminal/TerminalView.tsx— xterm.js integration with WebGL rendering, URL detection for OAuth flowcomponents/layout/— TopBar, MainTabs (the unified tab strip), Sidebar, StatusBarcomponents/projects/—ProjectRow(select-only list row),ProjectList,AddProjectDialog, and the editors reused by Project Homecomponents/projects/home/— Project Home, the main-area view for a project: Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in modals — see "UI conventions" below.components/settings/— Host-level settings: Docker, AWS, Web Terminal, STT, shared authcomponents/ui/— Shared primitives. Use these; do not hand-roll replacements.Modal(the only correct way to build a dialog — it suppliesrole="dialog",aria-modal, focus trap and restore),Button,Toggle,Field,SegmentedControl,StatusIndicator,SaveIndicator,OverflowMenu,ToastHost,Tooltip
UI conventions
- Project config belongs in Project Home's Config tab, not a modal. Modals are reserved for short, genuinely modal tasks (add project, confirm removal, token acquisition). The app previously had ~12 hand-rolled modals; they were consolidated deliberately.
- Never bypass the design tokens. All colour comes from CSS custom properties in
index.css. Filled buttons use--accent-emphasis(not--accent, which fails WCAG AA against white). Use--text-disabledrather thandisabled:opacity-50. - Never write
focus:outline-none. A global:focus-visiblering is defined inindex.css. - Status must not be encoded in colour alone —
StatusIndicatorpairs a glyph with a word. - Keyboard:
Ctrl+Tnew terminal,Ctrl+Shift+Wclose tab,Ctrl+Tabcycle,Ctrl+1..9jump.Ctrl+Wis intentionally left alone — it is readline'skill-wordinside the terminal.
Backend Structure (app/src-tauri/src/)
commands/— Tauri command handlers. These are the IPC entry points called byinvoke(). Beyond docker/project/settings/terminal:inspect_commands.rs(read-only views into a container — Claude sessions, installed capabilities, scheduler tasks),auth_bridge_commands.rs,auth_token_commands.rs.auth_bridge/— Host-side loopback bridge so browser logins run inside a container can complete against the host browser. Discovers listeners by parsing/proc/net/tcp{,6}(the image has noss/netstat/lsof), binds host127.0.0.1only, and tunnels in over the Docker API viasocat. 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'sPortForward, which binds an unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser. Host ports are confined to47820..=47827because CSPframe-srccannot express a port range and must enumerate them; a unit test asserts the Rust range matchestauri.conf.json. Opt-in per project.docker/— Docker API layer using bollard:client.rs— Singleton Docker connection viaOnceLockcontainer.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 streaminggateway.rs— Optional LiteLLM sibling container giving Claude Code an Anthropic-format front end for providers that only speak OpenAI (seegateway-container/). Mirrorsstt.rs. Binds0.0.0.0— unlike STT — because project containers, not the host process, consume it; it therefore always sets a LiteLLMmaster_key, since LiteLLM without one accepts any key.legacy_cleanup.rs— One-release migration shim removing leftovers from the deleted MCP feature (containers labelledtriple-c.mcp-server,triple-c-net-*networks). Deletable once users have migrated.
web_terminal/— Remote terminal access via axum HTTP+WebSocket server:server.rs— Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgradesws_handler.rs— Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnectterminal.html— Self-contained xterm.js web UI embedded viainclude_str!()
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 viakeyringcrate),settings_store.rs
Container (container/)
Dockerfile— Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installedentrypoint.sh— UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, thensleep infinitytriple-c-scheduler— Bash-based scheduled task system for recurring Claude Code invocations
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.
Reset is the exception and it is destructive. rebuild_project_container calls
remove_project_volumes, which deletes both volumes — so a Reset wipes ~/.claude,
~/.claude.json, the OAuth credential, installed skills, and session transcripts. That is
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
preserving credentials.
Authentication
Per-project, independently configured:
- Anthropic (OAuth) —
claude loginin 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) - llama.cpp — Connect to a local or remote
llama-serverviaANTHROPIC_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
- Tailwind CSS v4 with the Vite plugin (
@tailwindcss/vite). No separate tailwind config file. - All colors use CSS custom properties in
index.css:root(e.g.,--bg-primary,--text-secondary,--accent) color-scheme: darkis set on:rootfor native dark-mode controls- Do not add a global
* { padding: 0 }reset — Tailwind v4 uses CSS@layer, and unlayered CSS overrides all layered utilities
Key Conventions
- Frontend types in
lib/types.tsmust stay in sync with Rust structs inmodels/ - Tauri commands are registered in
lib.rsvia.invoke_handler(tauri::generate_handler![...]) capabilities/default.jsongrants permissions for plugin commands only (core:,dialog:,store:,opener:). Application commands registered throughgenerate_handler!do not need an entry there — adding one is not required and none exists for any app command.- The
projects.jsonfile uses atomic writes (write to.tmp, thenrename()). Corrupted files are backed up to.bak. - Adding project state that changes the container?
container_needs_recreation()is entirely label-based — it does not diff the container's env. If a new setting affects the container's environment or configuration, you must also write a correspondingtriple-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 viadocker inspect. - New model fields need an explicit serde default when the correct default isn't the zero value.
#[serde(default)]on aboolyieldsfalse; follow thedefault_full_permissionspattern inmodels/project.rsfor anything that should default to true. - Cross-platform paths: Docker socket is
/var/run/docker.sockon Linux/macOS,//./pipe/docker_engineon Windows
Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at src/test/setup.ts. Run a single test file:
cd app
npx vitest run src/path/to/test.test.ts