Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m29s
Build App / build-windows (push) Successful in 5m1s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 1m36s
802 lines
63 KiB
Markdown
802 lines
63 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
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:
|
|
```bash
|
|
cd app/src-tauri
|
|
cargo check # Type-check without full build
|
|
cargo build # Build Rust backend only
|
|
```
|
|
|
|
Container image:
|
|
```bash
|
|
docker build -t triple-c-sandbox ./container
|
|
```
|
|
|
|
### Linux Build Dependencies (Ubuntu/Debian)
|
|
```bash
|
|
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, keyed `term:<id>` and
|
|
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
|
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
|
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
|
`moveTab` deliberately does not activate what it moves.
|
|
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
|
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
|
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
|
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, 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.** `useKeyboardShortcuts`
|
|
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
|
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
|
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
|
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
|
and the editors reused by Project Home
|
|
- **`components/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 call `pick_files_to_upload` / `pick_save_path`, which drive
|
|
`tauri-plugin-dialog` from 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, a `link(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-introducing `open()`/`save()` in `FilesTab`
|
|
would 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 way `cp` does.
|
|
- **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`'s `onDragDropEvent`
|
|
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
|
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
|
at all; keep both halves and keep `PaneVisibility`). Backup is
|
|
`file_commands::download_container_backup`.
|
|
- **`resolve_host_path` applies 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 through `node_modules/.pnpm`, `~/.cache` or
|
|
`~/.local/share` is 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/nssdb`
|
|
through 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 (`~/.config` is the common one). Accepted, and not a reason to
|
|
narrow the rule, because the terminal drop and `download_container_backup` still 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_drag` and its
|
|
host staging directory were held back for separate hardening and live on
|
|
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or 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.
|
|
- **`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 on `hold/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 in `docker/container.rs`, capped
|
|
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
|
|
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
|
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
|
`Modal` (the only correct way to build a dialog — it supplies `role="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-disabled` rather than `disabled:opacity-50`.
|
|
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
|
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
|
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
|
|
`kill-word` inside the terminal, and plain `Ctrl+←/→` 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 by `invoke()`.
|
|
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 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.
|
|
- **`popout.rs` puts 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 with `destroy()`, never `close()`, to stay clear of
|
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
|
the browser.
|
|
- **`page.rs` opens 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_SIZE` at 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
|
|
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
|
continuously and each one costs a container exec.
|
|
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
|
`label() == "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@latest`
|
|
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
|
|
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
|
|
hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does
|
|
not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the
|
|
wrapper alone reports a current build as "predates `browser.bind()`".
|
|
- **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that 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.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is
|
|
*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-playwright` as `claude`, 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 chromium` downloads a browser that cannot launch, which
|
|
is why installing Chrome via apt looks like a fix. `install.rs` asks
|
|
`install-deps --dry-run` first 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 same `apt-get install` the fix would run, so check and fix cannot
|
|
disagree about what the dependency set is. Note that `--dry-run` exits **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/mcp` wants the `chrome` **channel** specifically, so both browsers are
|
|
offered.
|
|
- **`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.
|
|
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
|
- `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`, `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/`)
|
|
|
|
- **`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 the `vpn_support_enabled`
|
|
toggle 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 chromium` as root, so Playwright names its own
|
|
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames 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 — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
|
|
fonts) and nothing more, because `libgbm1` → `mesa-libgallium` → `libllvm20` is ~213 MiB that
|
|
no trimming removes.
|
|
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
|
|
platform Playwright's table does not cover, `install-deps` prints 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
|
|
runtime `apt-get install` of 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
|
|
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
|
- **`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.
|
|
|
|
### 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-certificates` globs `*.crt`, case-sensitively.** A `.pem` that 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 in `entrypoint.sh` (the Rust
|
|
side carries the unit tests). A single-file mount lands at `/tmp/.host-ca/<name>.crt` so 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 need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`;
|
|
Chrome/Chromium read neither and want their own NSS database at `~/.pki/nssdb`, seeded with
|
|
`certutil` (`libnss3-tools`, added to the image for this). The NSS step warns and continues if
|
|
`certutil` is 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 `$BROWSER` an image-level `ENV`. 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 the `MANAGED_AUTH_KEYS` reason: `docker commit`
|
|
bakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl, `openssl
|
|
s_client` and Python's `ssl` behave exactly as with the vars unset.
|
|
- **`triple-c.ca-fingerprint` covers 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 **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides 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 has `net_raw` but *not*
|
|
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
|
|
entirely from a default container — nothing to open even with the capability), and
|
|
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
|
|
container, since `/proc/sys` is 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's `tun`
|
|
module has to back it.
|
|
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
|
|
create --device /dev/does-not-exist` succeeds 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 from `start_container`, where it has a
|
|
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
|
|
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
|
|
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
|
|
- **`NET_ADMIN` here 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 shared `docker0` segment (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-support` is written unconditionally, including `false`.** The usual
|
|
`docker commit` reason: a `true` stamped 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.** `iproute2` and `wireguard-tools` are in
|
|
`container/Dockerfile` because 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. `iptables` is included and `nftables` deliberately 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
|
|
`/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
|
|
writable layer, so on a stop/start it is simply the same container's files, and on a recreation
|
|
`docker commit` has carried it into the snapshot. Traffic silently reverts to the real address.
|
|
Any future autostart or killswitch work starts here.
|
|
- **`/run` riding 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 the `wg.priv` a previous tunnel left in
|
|
`/run`. Anything writing key material there inherits the problem — the same `docker commit`
|
|
hazard as `triple-c.git-token-hash` and 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.
|
|
- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
|
|
nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
|
|
any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
|
|
the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
|
|
`nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
|
|
`xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment;
|
|
the kernel-config evidence is quoted there.
|
|
- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need
|
|
`xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every
|
|
provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it
|
|
breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in
|
|
systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route`
|
|
directly avoids both, which is what the skill does.
|
|
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
|
|
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
|
|
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
|
|
image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
|
|
The removal branch matters as much as the install: `~/.claude` is 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 as `0` rather than omitted (see
|
|
`vpn_env_var`, tested), and why it is in `RESERVED_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-skills` and no
|
|
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
|
|
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so 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 at `true` gets no recreation at all on upgrade.
|
|
|
|
### Keeping Claude Code current
|
|
|
|
`claude update` runs in **two** places, and both are needed:
|
|
|
|
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
|
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
|
command every Claude session launches with, because containers use a stop/start model and a
|
|
long-lived one would otherwise never re-check.
|
|
|
|
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
|
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
|
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
|
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
|
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
|
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
|
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
|
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
|
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
|
deliberately untouched.
|
|
|
|
### 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-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".**
|
|
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
|
|
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
|
|
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
|
|
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
|
|
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
|
|
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
|
|
for the same reason a running container already outranked it. Assuming a snapshot existed is what
|
|
made a stopped, never-recreated project report "no container or snapshot image yet" with its
|
|
container sitting right there, and left Update disabled on the projects furthest behind.
|
|
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
|
|
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
|
|
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
|
|
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
|
|
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
|
|
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
|
|
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
|
|
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
|
|
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
|
|
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
|
|
layer acceptable. Two rules it earned the hard way:
|
|
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
|
|
daemon-wide, so a second copy of the app has live probe images matching the glob.
|
|
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
|
|
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
|
|
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
|
|
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
|
|
two concurrent probes fought over one tag — whichever finished first force-removed the image the
|
|
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
|
|
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
|
|
uniqueness is what makes overlapping probes safe.
|
|
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
|
|
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
|
|
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
|
|
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
|
|
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
|
|
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
|
|
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
|
|
failed to invalidate would plan a migration against a filesystem the project no longer has.
|
|
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
|
|
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
|
|
which leaves the migrate action in the project menu enabled — that action is not gated on the
|
|
banner — so the pre-flight would report nothing to copy while the backend was told to copy
|
|
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
|
|
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
|
|
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
|
|
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
|
|
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
|
|
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
|
|
- **`get_container_staleness` never commits while the project is claimed.** It takes no
|
|
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
|
|
reads `project_lock::held` instead and probes the snapshot rather than the container. The
|
|
collision is not symmetric: the probe losing is a retryable `probe_error`, but
|
|
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
|
|
commit would fail the user's Start with an opaque error.
|
|
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
|
|
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
|
|
stamps *now* — which is what real probe images do, and what any fixture for them must do.
|
|
- **`: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`)
|
|
- **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
|
|
|
|
- **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: dark` is set on `:root` for 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.ts` must stay in sync with Rust structs in `models/`
|
|
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
|
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
|
|
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
|
|
need an entry there — adding one is not required and none exists for any app command.
|
|
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). 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 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.
|
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
|
|
|
## Secrets
|
|
|
|
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
|
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
|
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
|
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
|
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
|
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
|
for `container/**`.
|
|
|
|
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
|
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
|
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
|
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
|
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
|
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
|
catches the real incident (`9b2f4fe`) when replayed.
|
|
|
|
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
|
for it.
|
|
|
|
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
|
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
|
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
|
independent reviews, because every one of them read the code under change and this sat in a test
|
|
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
|
|
|
## Settings export/import
|
|
|
|
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
|
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
|
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
|
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
|
deliberately out of scope — this is not a project backup.
|
|
|
|
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
|
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
|
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
|
have carried it along as if it were as inert as a port number, and importing it would have
|
|
applied `web_terminal.enabled` and the token together with no more warning than any other
|
|
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
|
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
|
instead, with the same "only overwrite what the import actually has" treatment as the other
|
|
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
|
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
|
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
|
and the token are independent fields, and "this turns on a listening service" must not hide
|
|
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
|
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
|
like config but is actually a live credential would have the same problem.
|
|
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
|
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
|
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
|
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
|
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
|
header — the salt's job is only to make two exports of the same password derive different keys,
|
|
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
|
every export already gives it.
|
|
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
|
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
|
handing Rust a host path string is the exact shape of bug that produced this app's past
|
|
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
|
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
|
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
|
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
|
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
|
given this feature's own threat model: a file shared between people may sit in a synced or
|
|
otherwise shared directory that changes between the two calls.
|
|
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
|
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
|
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
|
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
|
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
|
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
|
render directly.
|
|
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
|
An import is "restore this environment," so the settings half is a full replace, not a
|
|
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
|
"the source machine never had this configured," not "delete this on import" — a user who wants
|
|
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
|
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
|
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
|
window where a gateway recreation happens against the destination's old keys.
|
|
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
|
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
|
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
|
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
|
alone, a running container would keep serving the old key material indefinitely after an import
|
|
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
|
actually written and, if the gateway is enabled and its container both exists and is running,
|
|
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
|
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
|
exactly when it should and no more.
|
|
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
|
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
|
in addition to logging it — an import that silently restores two of three secrets but not the
|
|
third must not read as unqualified success just because the settings half of the import (which
|
|
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
|
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
|
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
|
- **The imported settings are validated *before* any secret is written, not just before the
|
|
settings replace.** `apply_settings_import` calls
|
|
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
|
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
|
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
|
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
|
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
|
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
|
hint of.
|
|
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
|
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
|
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
|
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
|
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
|
here can hold a live credential.
|
|
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
|
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
|
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
|
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
|
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
|
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
|
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
|
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
|
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
|
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
|
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
|
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
|
a different server is visible in the confirmation dialog rather than discovered later — these are
|
|
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
|
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
|
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
|
token when none is already set, so a planted token would otherwise activate silently the next
|
|
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
|
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
|
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
|
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
|
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
|
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
|
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
|
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
|
it differs from the destination's current value, since re-importing the same risky configuration
|
|
is still worth surfacing every time a user confirms an import.
|
|
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
|
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
|
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
|
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
|
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
|
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
|
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
|
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
|
as a second layer against the same failure mode.
|
|
|
|
## Packaging
|
|
|
|
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
|
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
|
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
|
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
|
|
Windows are untouched.
|
|
|
|
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
|
|
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
|
|
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
|
|
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
|
|
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
|
|
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
|
|
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
|
|
metadata and update information**, without which an AppImage manager can adopt the app but never
|
|
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
|
|
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
|
|
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
|
|
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
|
|
|
|
**There is deliberately no Arch package.** A
|
|
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
|
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
|
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
|
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
|
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
|
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
|
the AppImage is the Arch story.
|
|
|
|
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
|
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
|
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
|
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
|
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
|
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
|
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
|
|
|
## Testing
|
|
|
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
|
```bash
|
|
cd app
|
|
npx vitest run src/path/to/test.test.ts
|
|
```
|