Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 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. Its bind address is detected, never0.0.0.0— unlike STT, project containers consume it, so loopback alone is not always enough: Docker Desktop gets127.0.0.1(containers reach it viahost.docker.internal), native Linux gets the default bridge gateway (172.17.0.1).GatewayBindingderives the bind address and the advertisedbase_urltogether 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 LiteLLMmaster_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 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
/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.
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.
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-idrecords the lineage but is deliberately not compared incontainer_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_stalenesssurfaces it;migrate_project_to_baseacts on it. - A missing lineage label means "unknown, probe instead", never "stale".
:latestkeeps pointing at the old lineage until the final commit. That is what makes every crash before that point self-heal —start_project_containerjust recreates from the old snapshot. After the container swap, the new container'striple-c.migration-state=in-progresslabel plus the persisted state file letreconcile_project_statusesoffer resume or rollback.- Rollback restores the system layer only. The volumes are never touched at any point, so work
done in
$HOMEduring a migrated session survives a rollback. Say so in any UI copy. /varis 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/postgresqlrides 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/liband/var/wwwthat the base does not ship and that hold non-dpkg-owned files (which is what keeps/var/lib/aptand/var/lib/dpkgout 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_snapshotthe commit is the only copy of the old system layer, so adocker tagthat fails — or succeeds without the reference resolving — aborts the migration beforeremove_container. Same rule in reverse forrollback_migration: the image is confirmed to exist before the container is destroyed. resumemust check the container'striple-c.migration-statelabel, exactly asreconcile_migrationdoes. 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 betweenremove_containerand the create that follows looks exactly like "no container" to Start, and Reset would delete the volumes out from under a live run. /etcis never copied, only reported: the snapshot lineage has/etc/apt/sources.list.d/nodesource.sourceswhere the current base hasnodesource.list, and having both breaks everyapt-get updateon a duplicate source. Verified, not theoretical.docker diffis 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 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. (triple-c.base-image-idis 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, anddocker commitcopies 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-fingerprinthas 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 defenceMANAGED_AUTH_KEYSapplies to env vars. - 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