`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
41 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.tabOrderis user-reorderable (drag, orCtrl+Shift+←/→viamoveActiveTab) — so never treat a tab's position as identity: address tabs by key, and index only throughtabOrder.moveTabdeliberately does not activate what it moves.- The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way. Tauri's
dragDropEnabledblocks HTML5 drag inside the webview on Windows, and it cannot simply be turned off:TerminalViewneeds Tauri's native drag-drop event because it is the only one that carries dropped file paths. An HTML5 drag also carries aDataTransfer, which the default handler types into any text field the drag is released over. - A new app-level shortcut must not swallow a text-editing chord.
useKeyboardShortcutsbinds ondocumentin the capture phase, soinTextField()guards the arrow bindings — excluding xterm's helper textarea, which is an input-method shim rather than a field.
- The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way. Tauri's
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.- The Files pane's host transfers open their dialog from Rust, and that is the whole
design — do not move it back into the webview. The tab browses, views (text and image),
renames and creates folders inside the container (
list_container_files,read_container_file,rename_container_path,create_container_directory), and it copies single files in and out (upload_files_to_container,download_container_file). The second pair callpick_files_to_upload/pick_save_path, which drivetauri-plugin-dialogfrom the backend: the webview can ask for a picker and that is the entirety of its influence — it cannot name a host path as an input. The claim stops there and should not be widened: host paths still travel outward in error text, canonical ones included. What is closed is the direction that produced the criticals. That shape is not decoration. Four successive audits found that host filesystem paths crossing IPC were where the criticals lived — a caller-named host destination for container-controlled bytes, an arbitrary host source read into the container, alink(2)upload reservation that succeeded against a directory and failed forever on any filesystem without hard links. The feature was removed rather than fixed a fifth time, and it came back only in the shape that removes the class: a frontend-driven dialog handing Rust a string is the exact thing that failed, so re-introducingopen()/save()inFilesTabwould undo the whole point while looking like a simplification. None of the reservation machinery came back with it. There is no destination reservation, no placeholder rollback and no collision marker — the OS save dialog already asks about overwriting, and Docker's archive extractor overwrites on upload the waycpdoes. - Drag-and-drop is still not it. There is no drop-into-the-Files-pane and no OS
drag-out; the buttons are the gesture. A file also gets in by being dropped on the
Terminal, and a whole tree comes out through "Back up container" — those two predate the
Files work and their hardening is not to be weakened.
TerminalView'sonDragDropEventis Tauri's native drop event (window-wide, so routed bylib/dropTarget.ts— geometry for whose drop it is, a document-widedropIsBlockedfor whether the app should accept one at all; keep both halves and keepPaneVisibility). Backup isfile_commands::download_container_backup. resolve_host_pathapplies the full lexical predicate twice — as written, and again after canonicalisation. That includes the general hidden-component rule, which deliberately over-catches: a path resolving throughnode_modules/.pnpm,~/.cacheor~/.local/shareis refused. Do not narrow it back to a list of "credential" directories. That was tried, and allow-by-omission let~/.local/bin(write there and you own the user's next shell command),~/.password-store, browser profiles and~/.pki/nssdbthrough a planted symlink with a perfectly visible name. Over-refusing is the cheaper mistake. Note the cost is real and has grown: of the four callers, the Files pane's two are routine, and their path comes from a dialog — so an over-catch refuses a destination a person actually chose (~/.configis the common one). Accepted, and not a reason to narrow the rule, because the terminal drop anddownload_container_backupstill take their host path over IPC and this predicate is their only boundary.- OS drag-out is not here.
tauri-plugin-drag,stage_container_file_for_dragand its host staging directory were held back for separate hardening and live onhold/disk-and-dragout. Do not re-adddrag:allow-start-dragor a staging command without taking that work back whole: the plugin has no scope mechanism, so the grant lets a compromised webview start a drag on any host path the user can read, and the staging directory is a host-temp disk leak with a gesture attached unless its exit-clear and startup-reap come back with it.
- The Files pane's host transfers open their dialog from Rust, and that is the whole
design — do not move it back into the webview. The tab browses, views (text and image),
renames and creates folders inside the container (
components/settings/— Host-level settings: Docker, AWS, Web Terminal, STT, shared auth. There is deliberately no Disk panel here. The disk survey and its reclaim / destroy / compaction surface were held back for separate hardening and live onhold/disk-and-dragout; one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it returns whole,generate_handler!entries and typed confirmations included. The prevention half stayed and is not disk-panel code: the pre-commit scrub indocker/container.rs, capped container logs, thetriple-c.base/triple-c.managedlabels,sweep_orphaned_snapshotsand the startup housekeeping inlib.rs, the migration reapers, andproject_lock.rs.components/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+Shift+←/→move the active tab.Ctrl+Wis intentionally left alone — it is readline'skill-wordinside the terminal, and plainCtrl+←/→is its word-wise cursor motion, which is why tab-moving takes Shift.
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.popout.rsputs the same URL in a second OS window (WebviewUrl::External), so the view can be watched on another monitor or pinned on top while the main window is used for work. Three things it rests on: no capability lists that window, so it has no IPC surface — do not give it one; the app CSP does not apply, because it is a top-level document rather than a frame, and the token gate is what protects the port in both cases; and the window is owned by the session, so the supervisor's teardown closes it rather than leaving a window onto a viewer that no longer exists. It closes withdestroy(), neverclose(), to stay clear ofCloseRequested. The pane drops its iframe while popped out — two viewers can both drive the browser.page.rsopens a page, which is the one thing the pane could not do. A URL plus a viewport: launch a browser in the container,browser.bind()it so the pane shows it, and keep the handle. Serves auth (the OAuth callback listener is in the container, so a container-side browser closes the loop with no host round trip and no auth bridge) and dev servers on container loopback. Verified: a second client cannot join a bound browser —chromium.connect()against the published endpoint times out in every URL form, because that socket speaks the dashboard's transport, not the public connect protocol. So whoever launches is the only process that can drive, which is why the helper is resident and why live resize applies to pages we opened and never to@playwright/mcp's (those take--viewport-size/PLAYWRIGHT_MCP_VIEWPORT_SIZEat launch). Control is a polled JSON file in/tmp— no port, no second listener — and a re-open with a helper already up navigates rather than relaunching, so a session signed in on one page survives to the next.- Resizing the window does not resize the page. The viewer is a CDP screencast: a bigger
window is the same pixels drawn larger.
page.setViewportSize()is what reflows (measured against a@media (max-width: 900px)rule), and match-window mode pushes the pop-out's settledResizedsize into it — debounced by generation counter, since a drag emits continuously and each one costs a container exec. lib.rs'son_window_eventfires for every window and must stay guarded onlabel() == "main". Without that guard, closing a pop-out runs the app's shutdown: every container stopped, process exited.- Detection has to look past
node_modules.claude mcp add … npx @playwright/mcp@latestinstalls into~/.npm/_npx/<hash>/node_modules, not anynode_modules, sodetect.rsglobs that cache as well as/workspace,$HOME/node_modulesandnpm root -g. It also hops from a wrapperplaywrightto its nestedplaywright-core: verified that npm does not hoist for global installs, and the wrapper ships notypes/types.d.ts, so reading the wrapper alone reports a current build as "predatesbrowser.bind()". @playwright/mcpcan never satisfy this pane. It bundles aplaywright-corethat binds, but never@playwright/cli, which is the viewer. Never offer it as a setup route — only as what binds sessions automatically once Playwright is present.install.rsinstalls into/workspace, asclaude, with--no-save./workspaceis not a bind mount — project directories are mounted at/workspace/{mount_name}— so this touches nothing of the user's, needs no sudo (npm's prefix is/usr, which is root-owned), and is on the module resolution path for scripts in the project. Browsers go to~/.cache/ms-playwrightasclaude, i.e. the home volume.- Current base images ship Chromium's shared libraries; older ones do not — and a project
keeps the base image it was first built from until it is migrated, so "older" is the normal
case. Without them
playwright install chromiumdownloads a browser that cannot launch, which is why installing Chrome via apt looks like a fix.install.rsasksinstall-deps --dry-runfirst and skips the apt step when the answer is "all present", saying so in the progress stream. Do not decide this by probing for library names: the dry-run simulates the sameapt-get installthe fix would run, so check and fix cannot disagree about what the dependency set is. Note that--dry-runexits 0 both when everything is installed and when Playwright has no list for the platform — match on its output, not its exit code. Either way the action ends by actually launching the browser to verify.@playwright/mcpwants thechromechannel specifically, so both browsers are offered.
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-installed, plus the shared libraries a browser links against (see below) and the VPN tooling thevpn_support_enabledtoggle grants capability for (iproute2,wireguard-tools,iptables)- Browser runtime libraries are baked in; browser binaries are not. A layer runs
npx --yes playwright@latest install-deps chromiumas root, so Playwright names its own dependencies and the list cannot rot against Ubuntu 24.04'st64renames or a new Chromium dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do not replace it with a hand-written apt list without pinning the Playwright version you derived it from — achromium-only list saves ~94 MiB (Playwright'stoolsgroup: xvfb and the CJK fonts) and nothing more, becauselibgbm1→mesa-libgallium→libllvm20is ~213 MiB that no trimming removes.- The
install-deps --dry-runcall after it is a build-time assertion, not decoration: on a platform Playwright's table does not cover,install-depsprints a warning and returns having installed nothing with exit status 0. Without the assertion that ships a broken image behind a clean build log. - Baking the libraries but not the browsers is the whole point of the split. Browsers live in
~/.cache/ms-playwright(home volume) and already survive recreation and migration; a runtimeapt-get installof the libraries lands in the writable layer, is re-paid after every Reset, and is lost on base-image migration, which replays apt from a manifest. The runtime approach converges on the worst state: a 400 MB browser present with its libraries gone. - The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
COPYs, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- The
entrypoint.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.
Corporate CA certificates (docker/ca_certs.rs, entrypoint.sh)
A global AppSettings::ca_cert_path with a per-project Project::ca_cert_path override, accepting
a single certificate file or a directory. Follows the SSH/AWS host-mount pattern: read-only
bind mount at /tmp/.host-ca, applied by the entrypoint on every start, so it survives recreation,
migration and Reset. Four things here are not obvious:
update-ca-certificatesglobs*.crt, case-sensitively. A.pemthat is merely copied into/usr/local/share/ca-certificates/is ignored in total silence. Certificates are renamed —container_cert_name()in Rust, mirrored in a few lines of shell inentrypoint.sh(the Rust side carries the unit tests). A single-file mount lands at/tmp/.host-ca/<name>.crtso the entrypoint only ever sees a directory and the file keeps a recognisable name.- The system store is not enough. Only curl/git/apt read it. Node — and therefore Claude Code
itself — needs
NODE_EXTRA_CA_CERTS; Python/requests needREQUESTS_CA_BUNDLE/SSL_CERT_FILE; Chrome/Chromium read neither and want their own NSS database at~/.pki/nssdb, seeded withcertutil(libnss3-tools, added to the image for this). The NSS step warns and continues ifcertutilis missing rather than failing the start. - Those env vars are set from Rust at creation, never exported by the entrypoint. A terminal
session is a
docker exec, which inherits the container's configured env and sees nothing the entrypoint exported — the same lesson that made$BROWSERan image-levelENV. The bundle path is deterministic (/etc/ssl/certs/ca-certificates.crt), so Rust can set them up front. They are emitted empty when no CA is configured, for theMANAGED_AUTH_KEYSreason:docker commitbakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl,openssl s_clientand Python'ssslbehave exactly as with the vars unset. triple-c.ca-fingerprintcovers the certificate bytes, not just the path. Replacing a rotated CA at the same location must recreate the container; the copy inside is made once, at start, so nothing else would notice. The entrypoint is stamped/idempotent on restart, and actively removestriple-c-*.crtwhen the setting is cleared —/usr/local/sharerides the project's snapshot image, so turning the feature off has to undo, not merely stop.
VPN support (vpn_support_enabled, docker/container.rs)
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
vpn_host_config() is the single definition of what that means, and it is unit-tested because a
container is created once by a very long function where a dropped capability is invisible.
- All three pieces or none.
CAP_NET_ADMIN(Docker's default set hasnet_rawbut notnet_admin, so a client can ping but never connect), the/dev/net/tundevice (absent entirely from a default container — nothing to open even with the capability), andnet.ipv4.conf.all.src_valid_mark=1(WireGuard'swg-quicksets it and cannot from inside a container, since/proc/sysis read-only, so handshake packets die to reverse-path filtering). Any two without the third still presents as a connection that hangs to a timeout, which is why the tests assert the whole set. - The device is passed through from the host, never
mknod-ed inside. The kernel'stunmodule has to back it. - A missing device fails at
start, notcreate— verified against Docker 29.7.docker create --device /dev/does-not-existsucceeds and prints an id; runc resolves the device (and validates sysctls) only when it builds the container. So the guard belongs on the start path:explain_container_failure()covers both and is called fromstart_container, where it has a container id and no project — which is why it keys off the error naming/dev/net/tunrather than offvpn_support_enabled. Nothing else in Triple-C requests a device, so that is unambiguous. A version of this check wired tocreatealone is dead code that looks correct. NET_ADMINhere is not user-namespaced. Docker does not enable userns remapping by default, so only the network namespace confines it: no reach onto host interfaces, but promiscuous mode, arbitrary addresses/routes/NAT on the shareddocker0segment (sibling containers, the LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the code comments honest about this — an earlier draft claimed it "confers no authority" outside the container, which is too strong.triple-c.vpn-supportis written unconditionally, includingfalse. The usualdocker commitreason: atruestamped once would ride the snapshot image into every future container and make the switch impossible to turn off.- Off is byte-identical to a container created before the feature existed, and a missing label
reads as
false, so no existing project is churned. - The toggle grants capability and stops there — it routes nothing.
vpn_host_config()returns a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started or restored. Users read the name as "turn the VPN on" and report the default network not routing through it as a bug. It isn't, and the docs say so explicitly; keep it that way. - The tooling is baked, not installed at runtime.
iproute2andwireguard-toolsare incontainer/Dockerfilebecause a runtime install lands in the writable layer and is lost on base-image migration — leaving a project holding the capability with nothing able to exercise it, and no error that points at why.iptablesis included andnftablesdeliberately is not; see the Dockerfile comment for why that way round. - Anything built on this fails open. The network namespace is rebuilt on every start and no
service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover
/runstate makes it look as though it did. Note the two different mechanisms:/runis in the writable layer, so on a stop/start it is simply the same container's files, and on a recreationdocker commithas carried it into the snapshot. Traffic silently reverts to the real address. Any future autostart or killswitch work starts here. /runriding the snapshot means a VPN client's key material can end up in an image. Verified: a fresh container off the whp snapshot already contained thewg.priva previous tunnel left in/run. Anything writing key material there inherits the problem — the samedocker commithazard astriple-c.git-token-hashand the custom-env fingerprint, in a directory that looks ephemeral and is not. A VPN client that does this should delete its key on teardown.iptablesis baked, and pickingnftablesinstead would have been wrong.Recommends: nftables | iptablesis stripped by--no-install-recommends, andwg-quickneeds a backend for anyAllowedIPs = 0.0.0.0/0.nftablesis the tempting choice — preferred bywg-quick, half the size — butwg-quickpicks nft unconditionally when present, and its nft ruleset needsnft_fib_ipv4, which LinuxKit (Docker Desktop for Mac) does not build while it does buildxt_CONNMARK. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment; the kernel-config evidence is quoted there.- Two
wg-quickfailures remain, and only one is ours to fix. Full tunnels still needxt_CONNMARK, which WSL2 before 6.6 lacks — nothing installable changes that. And every provider's stock config carries aDNS =line that fails inset_dns()before any routing, so it breaks split tunnels too;openresolvhas no candidate on noble andresolvconfdrags in systemd-resolved, so that one is documented rather than fixed. Drivingwgandip routedirectly avoids both, which is what the skill does. - The
pia-vpnskill is installed and removed fromVPN_SUPPORT_ENABLED.container/skills/is baked to/opt/triple-c-skillsandinstall_feature_skill()inentrypoint.shcopies it into~/.claude/skills/on every start — refreshed each time, so a fix reaches any project whose base image has the source, andrm -rf'd first, so files dropped from a later version do not linger. The removal branch matters as much as the install:~/.claudeis a persisted volume, so a skill left behind after the toggle goes off would keep instructing an agent to use a capability the container no longer has. Which is also why the variable is sent as0rather than omitted (seevpn_env_var, tested), and why it is inRESERVED_ENV_EXACT— a custom env var of that name could otherwise claim the skill without the capability behind it. - Both halves of that live in the base image, so neither reaches an existing project. A
recreation builds from the project's own snapshot, which has no
/opt/triple-c-skillsand no updatedentrypoint.sh; only a migration or a Reset delivers them. The install path says so out loud rather than returning silently, and/opt/triple-c-skillsis inFEATURE_PROBESso the migration pre-flight lists it as missing. Worth knowing before adding anything else behind an existing toggle: the label fingerprints the setting, not the set of things the setting drives, so a project already attruegets no recreation at all on upgrade.
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