Files
Triple-C/app/src-tauri/src/docker/container.rs
T
shadow-testandClaude Opus 5 d09e2a2743 Stop using a live credential as a test fixture
`the_custom_env_fingerprint_never_carries_the_value` asserted that the custom-env
fingerprint does not leak a secret — using the maintainer's real Gitea token as
the secret. It was committed on 2026-08-11 in 9b2f4fe, reached 92 commits, and
was readable for fourteen days in the public GitHub mirror at
`shadowdao/triple-c`, confirmed by fetching the raw file.

The token was a **site-admin** token (`is_admin: true`, user id 1) with admin
rights on every repository the account can see, not a repo-scoped one. It has
been revoked; the API now answers 401.

Release bundles were never affected — the literal is inside `#[cfg(test)]`, and
a search of the shipped 0.4.62 AppImage finds nothing.

The fixture is now an obviously fake string, and the test is unchanged
otherwise. Mutation-checked against the replacement: a fingerprint that returns
the raw value, one that returns the key name, and one that ignores its input are
all still caught, so nothing about the test's power depended on the value being
real — which was true the whole time.

History is deliberately **not** rewritten. The value was public for two weeks, so
rotation is the fix and the old value is now worthless; rewriting 92 commits of
published history would break every clone to hide something already seen.

Worth noting how this survived: five audit rounds and two independent reviews
all pointed at new code, and this sat in a test file that none of them had reason
to open. A high-entropy-literal check in CI would have caught it on the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
2026-08-25 11:29:16 -07:00

6127 lines
278 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::ca_certs;
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column
- `triple-c-scheduler remove --id ID` — Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
### Cron format
Standard 5-field cron: `minute hour day-of-month month day-of-week`
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
### One-time tasks
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
### Working directory
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results
While a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
const MISSION_CONTROL_GLOBAL_INSTRUCTIONS: &str = r#"## Mission Control
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
### How It Works
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
- The methodology docs and project registry live in `/workspace/mission-control/`
### When to Use
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
3. Use Mission Control skills for planning and execution
### Available Skills
| Skill | When to Use |
|-------|-------------|
| `/init-project` | Setting up a new project for Flight Control |
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
| `/flight-debrief` | Post-flight analysis after a flight lands |
| `/mission-debrief` | Post-mission retrospective after completion |
| `/daily-briefing` | Cross-project status report |
### Key Rules
- **Planning skills produce artifacts only** — never modify source code directly
- **Phase gates require human confirmation** — missions before flights, flights before legs
- **Legs are immutable once in-flight** — create new ones instead of modifying
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
- **Artifacts live in the target project** — not in mission-control"#;
const MISSION_CONTROL_PROJECT_INSTRUCTIONS: &str = r#"## Flight Operations
This project uses **Flight Control** (bundled with Triple-C) for structured development.
**Before any mission/flight/leg work, read these files in order:**
1. `.flightops/README.md` — What the flightops directory contains
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
const SANDBOX_INSTRUCTIONS: &str = r#"## Sandbox Mode
This container has Claude Code's bash sandbox enabled, managed by Triple-C
(toggle it from the project's "Sandbox mode" switch in the Triple-C UI).
Bash commands run inside `bubblewrap` with filesystem and network isolation
(`enableWeakerNestedSandbox` is on because we are inside Docker).
### When a command fails because of sandbox restrictions
Triple-C disables the `dangerouslyDisableSandbox` escape hatch
(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the
sandbox at runtime. To make a blocked command work, edit
`~/.claude/settings.json` and restart Claude Code:
| Need | Setting |
|---|---|
| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` |
| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` |
| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `"docker *"`) to `sandbox.excludedCommands` |
### Docker commands
The `docker` CLI does not work inside the sandbox. If this project has
"Allow container spawning" enabled in Triple-C and you need to run
`docker` commands, add `"docker *"` to `sandbox.excludedCommands` in
`~/.claude/settings.json`. Other tools known to be sandbox-incompatible
include `watchman` — pass `--no-watchman` to `jest`.
### Disabling sandbox mode
Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
on every container start. To turn sandbox off, stop the container in
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
/// the same value is produced in both paths.
fn build_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
mission_control_enabled: bool,
sandbox_enabled: bool,
) -> Option<String> {
let mut combined = merge_claude_instructions(
global_instructions,
project_instructions,
mission_control_enabled,
);
if !port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SCHEDULER_INSTRUCTIONS),
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
if sandbox_enabled {
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
None => SANDBOX_INSTRUCTIONS.to_string(),
});
}
combined
}
/// The env var Claude Code reads a long-lived `claude setup-token` credential
/// from. Named once so injection, the reserved-name blocklist, and the
/// stale-value neutralization pass can never disagree about the spelling.
pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";
/// Every managed env var whose *value* is a credential.
///
/// These are the names that must never survive into a snapshot image. A
/// container's env is visible to `docker inspect`, which is bad but bounded —
/// the container is recreated whenever the credential rotates, and removed
/// with the project. An **image**'s env is neither: `docker commit` copies the
/// container's full environment into `triple-c-snapshot-{id}:latest`, that tag
/// outlives every container built from it, and nothing about deleting a
/// keychain entry touches it. A ~1-year OAuth token baked in that way is
/// readable by `docker image inspect` for as long as the image exists, long
/// after the user has clicked Revoke.
///
/// [`commit_container_snapshot`] therefore blanks all of them at commit time,
/// and [`scrub_secrets_from_snapshots`] rewrites images committed before that
/// was true.
///
/// Blanked rather than omitted, because Docker's commit endpoint *merges* the
/// supplied config over the container's rather than replacing it: a key left
/// out of the list is inherited with its original value, so `KEY=` is the only
/// way to clear one. That matches how `MANAGED_AUTH_KEYS` already works at
/// create time, and Claude Code, the AWS SDK and git all treat an empty value
/// as unset.
pub const SECRET_ENV_KEYS: &[&str] = &[
CLAUDE_OAUTH_TOKEN_ENV,
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
"GIT_TOKEN",
];
/// Env var name prefixes Triple-C manages itself; users cannot set these by hand.
/// The label every container Triple-C creates carries — and, because
/// `docker commit` copies a container's labels onto the image, every snapshot it
/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance,
/// which is what keeps the sweep away from the user's own images.
pub(crate) const LABEL_MANAGED: &str = "triple-c.managed";
/// Marks the throwaway container [`rewrite_image_without_secrets`] commits
/// from. It exists so the Disk panel's reclaim bucket can distinguish a live
/// credential rewrite from a leftover by label rather than by age.
pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub";
/// Marks the image built from `container/Dockerfile` itself, as opposed to a
/// project snapshot committed from a container. Only ever `"true"` on a base
/// image; `create_container` writes it explicitly empty so an inherited value
/// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile.
pub(crate) const LABEL_BASE: &str = "triple-c.base";
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
/// Exact env var names Triple-C manages itself. Not covered by
/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes.
///
/// `MCP_SERVERS_JSON` is reserved for legacy reasons: the built-in MCP feature
/// was removed, but the name stays blocked so users cannot hand-set it.
/// `CLAUDE_CODE_OAUTH_TOKEN` is reserved because Triple-C owns it — a hand-set
/// value would silently outrank the keychain-held shared token and be invisible
/// to the auth UI.
const RESERVED_ENV_EXACT: &[&str] = &[
"CLAUDE_INSTRUCTIONS",
"MCP_SERVERS_JSON",
"CLAUDE_CODE_SETTINGS_JSON",
"CLAUDE_CODE_SETTINGS_CLEAR",
"MISSION_CONTROL_ENABLED",
"VPN_SUPPORT_ENABLED",
"TRIPLE_C_PERMISSION_MODE",
// The four env vars the Claude Code settings editor drives. Reserved for
// the `VPN_SUPPORT_ENABLED` reason: each is now written on every create,
// including its off value, so a hand-set custom var of the same name would
// either be overridden without explanation or override the setting behind
// the UI's back, depending on which one Docker kept.
"CLAUDE_CODE_NO_FLICKER",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB",
"ENABLE_PROMPT_CACHING_1H",
CLAUDE_OAUTH_TOKEN_ENV,
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
// above; they are listed explicitly so that a future narrowing of the
// prefix list cannot silently unreserve them, and so `is_reserved_env_key`
// reads as the single, complete statement of what Triple-C owns.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
];
/// Claude Code's model-alias env vars. Each names the concrete model id that
/// one of the `opus` / `sonnet` / `haiku` / `fable` aliases resolves to.
///
/// `ANTHROPIC_DEFAULT_HAIKU_MODEL` is the important one: it is documented as
/// *"Model ID that the `haiku` alias resolves to, also used for background
/// functionality"* — conversation titles, summarisation, and other out-of-band
/// calls. Left unset against a local server, Claude Code sends
/// Anthropic's own Haiku model id to a server that has never heard of it and
/// every background call fails, usually silently.
///
/// (`ANTHROPIC_SMALL_FAST_MODEL` is the deprecated predecessor of the Haiku
/// var and is deliberately *not* used.)
pub const ANTHROPIC_DEFAULT_OPUS_MODEL: &str = "ANTHROPIC_DEFAULT_OPUS_MODEL";
pub const ANTHROPIC_DEFAULT_SONNET_MODEL: &str = "ANTHROPIC_DEFAULT_SONNET_MODEL";
pub const ANTHROPIC_DEFAULT_HAIKU_MODEL: &str = "ANTHROPIC_DEFAULT_HAIKU_MODEL";
pub const ANTHROPIC_DEFAULT_FABLE_MODEL: &str = "ANTHROPIC_DEFAULT_FABLE_MODEL";
/// Resolve the four `ANTHROPIC_DEFAULT_*_MODEL` values for a backend that
/// points Claude Code at a custom endpoint.
///
/// All four aliases fall back to `effective_model` — the backend's configured
/// model id, already resolved per-project → global. That is the right default:
/// a local server almost always serves exactly one model, so every alias must
/// name it or the calls that use an alias (notably the background ones, which
/// use `haiku`) go to a model the server does not have.
///
/// `haiku_override` exists because that is the one alias someone might
/// legitimately want to point elsewhere — at a second, smaller server-side
/// model kept for cheap background work. A blank override falls back to
/// `effective_model` like the others.
///
/// Returns pairs in `(name, value)` form; a blank resolved value emits nothing
/// at all rather than an empty var, so an unconfigured backend is left exactly
/// as Claude Code found it.
pub fn compute_model_aliases(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> Vec<(&'static str, String)> {
let base = effective_model.map(str::trim).filter(|s| !s.is_empty());
let haiku = haiku_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or(base);
let mut out: Vec<(&'static str, String)> = Vec::new();
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_OPUS_MODEL, m.to_string()));
out.push((ANTHROPIC_DEFAULT_SONNET_MODEL, m.to_string()));
}
if let Some(h) = haiku {
out.push((ANTHROPIC_DEFAULT_HAIKU_MODEL, h.to_string()));
}
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_FABLE_MODEL, m.to_string()));
}
out
}
/// The fingerprint contribution of the model aliases, so that changing an
/// alias (or the model it falls back to) forces a container recreation.
/// `container_needs_recreation` is label-based and never diffs env, so an
/// env-only change is invisible without this.
fn model_alias_fingerprint_part(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> String {
compute_model_aliases(effective_model, haiku_override)
.into_iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",")
}
/// Whether `key` is an env var name Triple-C reserves for itself.
fn is_reserved_env_key(key: &str) -> bool {
let upper = key.to_uppercase();
RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p))
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
}
/// Compute a fingerprint for the custom environment variables.
///
/// Sorted alphabetically so order changes do not cause spurious recreation, and
/// **hashed**, because this value is written as the
/// `triple-c.custom-env-fingerprint` label. Labels are readable by anything on
/// the host through `docker inspect`, `docker commit` copies them onto the
/// project's snapshot image, and `container_needs_recreation` logs both sides on
/// a mismatch — so a plaintext `KEY=VALUE` join published every custom
/// variable's *value*, API tokens included, to all three places. Same treatment
/// as `triple-c.git-token-hash`.
///
/// Empty stays empty rather than becoming the hash of the empty string: an empty
/// label is how every other `triple-c.*` key says "nothing configured".
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars {
let key = env_var.key.trim();
if key.is_empty() || is_reserved_env_key(key) {
continue;
}
parts.push(format!("{}={}", key, env_var.value));
}
parts.sort();
if parts.is_empty() {
return String::new();
}
sha256_hex(&parts.join(","))
}
/// The shared Claude Code OAuth token to inject for this project, paired with
/// its rotation id.
///
/// `None` unless *all* of: the backend is Anthropic (the token is meaningless
/// to Bedrock/Ollama/OpenAI-compatible), the project has not opted out, and a
/// non-blank token is actually in the keychain. Read here rather than passed in
/// because the token is global, not part of the per-project record.
///
/// The returned token is never logged and never leaves this module except as
/// the env var value handed to Docker.
fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
if project.backend != Backend::Anthropic || !project.use_shared_auth_token {
return None;
}
let token = crate::storage::secure::get_claude_oauth_token()
.unwrap_or_else(|e| {
log::warn!("Could not read the shared Claude token from the keychain: {}", e);
None
})
.filter(|t| !t.trim().is_empty())?;
// A token with no rotation id predates versioning (or the id write failed).
// A constant stand-in still differs from the empty "no token" label, so
// presence changes are caught; only rotations could be missed.
let version = crate::storage::secure::get_claude_oauth_token_version()
.unwrap_or(None)
.unwrap_or_else(|| "unversioned".to_string());
Some((token, version))
}
/// Label value tracking which shared Claude token (if any) a container was
/// created with. Empty means "none injected". See
/// [`crate::storage::secure`] for why this is a random rotation id rather than
/// a hash of the token.
fn claude_token_label(project: &Project) -> String {
shared_claude_auth(project)
.map(|(_, version)| version)
.unwrap_or_default()
}
/// Merge global and per-project custom environment variables.
/// Per-project variables override global variables with the same key.
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
for ev in global {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
for ev in project {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
merged.into_values().collect()
}
/// Merge global and per-project Claude instructions into a single string.
/// When mission_control_enabled is true, appends Mission Control global
/// instructions after global and project instructions after project.
fn merge_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
mission_control_enabled: bool,
) -> Option<String> {
// Build the global portion (user global + optional MC global)
let global_part = if mission_control_enabled {
match global_instructions {
Some(g) => Some(format!("{}\n\n{}", g, MISSION_CONTROL_GLOBAL_INSTRUCTIONS)),
None => Some(MISSION_CONTROL_GLOBAL_INSTRUCTIONS.to_string()),
}
} else {
global_instructions.map(|g| g.to_string())
};
// Build the project portion (user project + optional MC project)
let project_part = if mission_control_enabled {
match project_instructions {
Some(p) => Some(format!("{}\n\n{}", p, MISSION_CONTROL_PROJECT_INSTRUCTIONS)),
None => Some(MISSION_CONTROL_PROJECT_INSTRUCTIONS.to_string()),
}
} else {
project_instructions.map(|p| p.to_string())
};
match (global_part, project_part) {
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
(Some(g), None) => Some(g),
(None, Some(p)) => Some(p),
(None, None) => None,
}
}
/// Hash a string with SHA-256 and return the hex digest.
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Resolve a per-project string value with a global fallback. Returns `None`
/// when both are blank, otherwise the per-project value if set, else the global.
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
if project_val.is_some() {
return project_val;
}
global.map(str::trim).filter(|s| !s.is_empty())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
/// Includes the resolved model_id (per-project blank → global default) so that
/// changing the global default forces a container recreation.
fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let effective_model = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
).unwrap_or("").to_string();
// NOTE: the static credential fields (access key / secret / session
// token) are intentionally NOT part of the fingerprint. They are
// written to ~/.aws/credentials on every start by
// sync_bedrock_credentials(), so a key rotation should refresh
// in place rather than force a full container recreation. Region,
// profile, and bearer token remain env-based and so stay here.
let parts = vec![
format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(),
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
effective_model,
format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
/// Includes the resolved base_url and model_id (per-project blank → global
/// default) and the resolved model aliases.
///
/// NOTE: adding the alias part changes this hash for every existing Ollama
/// container, so each will be recreated once on the next start. That is exactly
/// what is wanted — recreation is the only way to get the new
/// `ANTHROPIC_DEFAULT_*_MODEL` vars into the container's env.
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config {
let effective_url = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the llama.cpp configuration so we can detect
/// changes. Mirrors [`compute_ollama_fingerprint`].
fn compute_llamacpp_fingerprint(
project: &Project,
global_llamacpp: &GlobalLlamaCppSettings,
) -> String {
if let Some(ref cfg) = project.llamacpp_config {
let effective_url = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
/// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_openai_compatible_fingerprint(
project: &Project,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
) -> String {
if let Some(ref config) = project.openai_compatible_config {
let effective_url = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
),
);
let parts = vec![
effective_url,
config.api_key.as_deref().unwrap_or("").to_string(),
effective_model,
aliases,
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the project paths so we can detect changes.
/// Sorted by mount_name so order changes don't cause spurious recreation.
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
let mut parts: Vec<String> = paths
.iter()
.map(|p| format!("{}:{}", p.mount_name, p.host_path))
.collect();
parts.sort();
let joined = parts.join(",");
sha256_hex(&joined)
}
/// Compute a fingerprint for port mappings so we can detect changes.
/// Sorted so order changes don't cause spurious recreation.
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
let mut parts: Vec<String> = port_mappings
.iter()
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
.collect();
parts.sort();
let joined = parts.join(",");
sha256_hex(&joined)
}
/// Merge global and per-project `ClaudeCodeSettings`.
///
/// A project field that is `Some` wins outright — **including `Some(false)`**.
/// That is the point of the widening: these used to be plain `bool`s ORed
/// together (`if p.x { true } else { g.x }`), so a project could only ever add
/// to the global set and never turn a globally-enabled setting off. `None` at
/// project level means "inherit", which is now a state the project can
/// actually be in rather than the only state an off switch could produce.
fn merge_claude_code_settings(
global: Option<&ClaudeCodeSettings>,
project: Option<&ClaudeCodeSettings>,
) -> Option<ClaudeCodeSettings> {
match (global, project) {
(None, None) => None,
(Some(g), None) => Some(g.clone()),
(None, Some(p)) => Some(p.clone()),
(Some(g), Some(p)) => {
Some(ClaudeCodeSettings {
tui_mode: p.tui_mode.clone().or_else(|| g.tui_mode.clone()),
effort: p.effort.clone().or_else(|| g.effort.clone()),
auto_scroll_disabled: p.auto_scroll_disabled.or(g.auto_scroll_disabled),
focus_mode: p.focus_mode.or(g.focus_mode),
show_thinking_summaries: p.show_thinking_summaries.or(g.show_thinking_summaries),
session_recap_disabled: p.session_recap_disabled.or(g.session_recap_disabled),
env_scrub: p.env_scrub.or(g.env_scrub),
prompt_caching_1h: p.prompt_caching_1h.or(g.prompt_caching_1h),
})
}
}
}
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
/// a container recreation (re-injecting the merged settings.json).
///
/// **This formula changed, and the change is not free.** It used to read
/// `format!("{}", bool)`; the booleans are now `Option<bool>` and it reads
/// `format!("{:?}")`, because `None` (inherit) and `Some(false)` (a deliberate
/// off) must not hash alike — conflating them leaves a container un-recreated
/// on a real change. The consequence is that **every existing project holding
/// a settings object gets a different fingerprint on first launch after this
/// upgrade, and is recreated once.** A recreation commits a snapshot layer, so
/// that is a one-off disk cost per project, paid silently.
///
/// An earlier version of this comment claimed the opposite — that "the
/// historical fingerprint is preserved unchanged so that upgrading triple-c
/// does not spuriously flag every existing container for recreation." That was
/// carried over from before the widening and was false the moment the format
/// string changed. It is recorded here because a reviewer who believed it would
/// conclude the churn cannot happen.
fn compute_claude_code_settings_fingerprint(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> String {
let base_fp = match settings {
None => String::new(),
Some(s) => {
let parts = vec![
s.tui_mode.as_deref().unwrap_or("").to_string(),
s.effort.as_deref().unwrap_or("").to_string(),
// `{:?}` rather than `{}` so `None` and `Some(false)` produce
// different text. They mean different things — inherit versus a
// deliberate off — and a fingerprint that conflated them would
// leave the container un-recreated on a real change.
format!("{:?}", s.auto_scroll_disabled),
format!("{:?}", s.focus_mode),
format!("{:?}", s.show_thinking_summaries),
format!("{:?}", s.session_recap_disabled),
format!("{:?}", s.env_scrub),
format!("{:?}", s.prompt_caching_1h),
];
sha256_hex(&parts.join("|"))
}
};
if sandbox_enabled {
sha256_hex(&format!("{}|sandbox=true", base_fp))
} else {
base_fp
}
}
/// The four Claude Code env vars the settings editor drives, as `KEY=VALUE`.
///
/// **All four are emitted on every create, including their off value.** This is
/// the `MANAGED_AUTH_KEYS` rule: `docker commit` bakes a container's env into
/// the snapshot image, and the next container inherits anything the create does
/// not override. A `=1` written once would ride that snapshot into every future
/// container and make the switch impossible to turn back off — the same
/// stickiness [`build_claude_code_settings_json`] fixes on the settings.json
/// side, in a place where it is even less visible.
///
/// Two of them use an **empty** value for "off", and the distinction matters:
///
/// * `CLAUDE_CODE_NO_FLICKER` documents `1` as fullscreen-on and `0` as
/// fullscreen-*off*, and it overrides the `tui` setting. `0` is therefore not
/// neutral — it would silently pin every project that has expressed no
/// preference to the classic renderer, when an unset `tui` is supposed to let
/// Claude Code choose. Empty is neither value, so it reads as unset while
/// still overriding a baked `1`.
/// * `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` outranks both `awaySummaryEnabled` and
/// the in-container `/config` toggle. `0` is exactly right for "the user
/// turned the recap off in Triple-C", but a blanket `1` for the default state
/// would force the recap back on for someone who had turned it off with
/// `/config` inside their own container. Triple-C's default must not overrule
/// a choice it never asked about.
///
/// The other two are documented as "set to `1` to …" with no meaning attached
/// to `0`, so `0` is unambiguously neutral and is stated outright.
fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
let owned;
let s = match settings {
Some(s) => s,
None => {
owned = ClaudeCodeSettings::default();
&owned
}
};
vec![
format!(
"CLAUDE_CODE_NO_FLICKER={}",
match s.tui_mode.as_deref() {
Some("fullscreen") => "1",
Some("default") => "0",
_ => "",
}
),
format!(
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY={}",
if s.session_recap_disabled.unwrap_or(false) { "0" } else { "" }
),
format!(
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB={}",
if s.env_scrub.unwrap_or(false) { "1" } else { "0" }
),
format!(
"ENABLE_PROMPT_CACHING_1H={}",
if s.prompt_caching_1h.unwrap_or(false) { "1" } else { "0" }
),
]
}
/// Build the settings.json payload for Claude Code, handed to the container as
/// `CLAUDE_CODE_SETTINGS_JSON` and applied by `entrypoint.sh`.
///
/// ## Every managed key is always present, and `null` means "delete"
///
/// The settings file lives on `triple-c-claude-config-{projectId}`, a named
/// volume that outlives the container, and the entrypoint *merges* into it. So
/// a key emitted only when it is non-default can be written once and never
/// taken back: turning the setting off simply omits the key, the merge
/// preserves whatever was there, and the setting stays on forever. Only a
/// destructive Reset — which also deletes the OAuth login, skills and
/// transcripts — ever cleared it. Four of the six keys here were sticky that
/// way; the `sandbox` block already carried the workaround and the comment
/// explaining it, and this is the same treatment applied to the rest.
///
/// Two shapes of "off" are needed, because Claude Code's own defaults differ:
///
/// * **A boolean with a documented default** (`autoScrollEnabled` is `true`,
/// `showThinkingSummaries` is `false`) is emitted with that neutral value.
/// * **A key whose neutral state is *unset*** (`tui`, `effortLevel`,
/// `viewMode`, `awaySummaryEnabled`) is emitted as JSON `null`, and the
/// entrypoint deletes rather than merges those. Writing a stand-in value
/// would not be neutral: an unset `tui` lets Claude Code choose the renderer
/// (`"default"` pins the classic one), and an unset `viewMode` lets the
/// user's own sticky `/focus` choice and `verbose` setting apply
/// (`"default"` overrides both).
///
/// Returns a `String` rather than an `Option<String>`: there is no longer any
/// input for which this produces nothing to say.
fn build_claude_code_settings_json(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> String {
let owned;
let s = match settings {
Some(s) => s,
// No struct at all is not "say nothing" — it is "every setting is at
// its default", which still has to be asserted over a stale file.
None => {
owned = ClaudeCodeSettings::default();
&owned
}
};
let mut map = serde_json::Map::new();
// `null` clears; see the module doc above.
map.insert(
"tui".to_string(),
match s.tui_mode {
Some(ref tui) => serde_json::json!(tui),
None => serde_json::Value::Null,
},
);
// `effortLevel`, not `effort`. Claude Code has never read a key called
// `effort`, so the previous value was written and silently ignored.
map.insert(
"effortLevel".to_string(),
match s.effort {
Some(ref effort) => serde_json::json!(effort),
None => serde_json::Value::Null,
},
);
// Documented default `true`, so the neutral value is a value.
map.insert(
"autoScrollEnabled".to_string(),
serde_json::json!(!s.auto_scroll_disabled.unwrap_or(false)),
);
// Documented default `false`.
map.insert(
"showThinkingSummaries".to_string(),
serde_json::json!(s.show_thinking_summaries.unwrap_or(false)),
);
// `viewMode: "focus"` is the real setting behind what the UI calls focus
// mode — "collapses tool output to one-line summaries" is that key's
// documented behaviour. The `focusMode` key it replaces was invented and
// did nothing.
map.insert(
"viewMode".to_string(),
if s.focus_mode.unwrap_or(false) {
serde_json::json!("focus")
} else {
serde_json::Value::Null
},
);
// The recap is on by default, so only the *off* case has anything to write.
// `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` (set unconditionally at creation) takes
// precedence over this key and is what actually enforces the choice; this
// is here so the container's settings.json does not contradict it.
map.insert(
"awaySummaryEnabled".to_string(),
if s.session_recap_disabled.unwrap_or(false) {
serde_json::json!(false)
} else {
serde_json::Value::Null
},
);
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
// off in triple-c clears any prior on-state in the persisted
// settings.json (which lives in a named volume that survives recreation).
// Inside a Docker container we can't rely on privileged user namespaces,
// so `enableWeakerNestedSandbox` is required when sandbox is on.
let sandbox_obj = if sandbox_enabled {
serde_json::json!({
"enabled": true,
"enableWeakerNestedSandbox": true,
"allowUnsandboxedCommands": false,
})
} else {
serde_json::json!({ "enabled": false })
};
map.insert("sandbox".to_string(), sandbox_obj);
serde_json::Value::Object(map).to_string()
}
/// Split the managed payload into the half that is safe to merge anywhere and
/// the list of keys to delete.
///
/// The null-means-delete convention is only understood by the `entrypoint.sh`
/// shipped alongside this code — and an existing project recreates from *its
/// own snapshot image*, which carries whatever entrypoint it was built with. An
/// older one merges with a plain `.[0] * .[1]`, which would write the literal
/// `null`s straight into the user's `settings.json` rather than clearing the
/// keys. A settings file Claude Code then rejects would take the user's own
/// `model`, `statusLine` and everything else in it down with it.
///
/// So the nulls never leave Rust. `CLAUDE_CODE_SETTINGS_JSON` carries only real
/// values and stays safe under either merge; `CLAUDE_CODE_SETTINGS_CLEAR`
/// carries the key names to delete and is simply ignored by an entrypoint that
/// predates it. Such a project keeps the old sticky behaviour until it is
/// migrated or Reset — which is the pre-existing state, not a regression.
pub(crate) fn split_claude_code_settings_payload(payload: &str) -> (String, String) {
let parsed: serde_json::Value = match serde_json::from_str(payload) {
Ok(v) => v,
// Not our business to fix; hand it through and let the merge fail loudly.
Err(_) => return (payload.to_string(), "[]".to_string()),
};
let Some(obj) = parsed.as_object() else {
return (payload.to_string(), "[]".to_string());
};
let mut set = serde_json::Map::new();
let mut clear: Vec<serde_json::Value> = Vec::new();
for (k, v) in obj {
if v.is_null() {
clear.push(serde_json::json!(k));
} else {
set.insert(k.clone(), v.clone());
}
}
(
serde_json::Value::Object(set).to_string(),
serde_json::Value::Array(clear).to_string(),
)
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?;
let container_name = project.container_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([
("name".to_string(), vec![container_name.clone()]),
]);
let containers: Vec<ContainerSummary> = docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list containers: {}", e))?;
// Match exact name (Docker prepends /)
let expected = format!("/{}", container_name);
for c in &containers {
if let Some(names) = &c.names {
if names.iter().any(|n| n == &expected) {
return Ok(c.id.clone());
}
}
}
Ok(None)
}
/// Extra creation inputs that only base-image migration cares about, kept in
/// one struct so `create_container`'s already-long parameter list does not grow
/// two more positional arguments that every ordinary call site would have to
/// pass as `None`-ish placeholders.
#[derive(Debug, Clone, Copy, Default)]
pub struct CreateExtras<'a> {
/// Extra labels merged in last, overriding anything computed here.
/// Migration uses this to stamp `triple-c.migration-state=in-progress`.
pub extra_labels: &'a [(&'a str, &'a str)],
}
/// Resolve the value for the `triple-c.base-image-id` label.
///
/// This is the **image ID**, not a `RepoDigests` entry: a locally built image
/// (`triple-c:latest`) and any custom image have no repo digest at all, so a
/// digest-based lineage would be blank for exactly the users most likely to
/// change their base.
///
/// Two cases:
/// * creating **from the base** — the base's own current `.Id`;
/// * creating **from the project's snapshot** — carry forward whatever lineage
/// the snapshot image already records, because a snapshot is a commit of a
/// container that itself descended from some base. Committing propagates
/// container labels onto the image (verified), which is what makes the
/// carry-forward chain hold across every recreation.
///
/// An empty string means "unknown" — a snapshot that predates this label. It is
/// deliberately *not* the same as "stale"; see [`crate::models::ContainerStaleness::known`].
async fn resolve_base_image_id(image_name: &str, base_image_name: &str) -> String {
if image_name == base_image_name {
return super::migration::image_id(base_image_name)
.await
.ok()
.flatten()
.unwrap_or_default();
}
super::migration::image_labels(image_name)
.await
.get(super::migration::LABEL_BASE_IMAGE_ID)
.cloned()
.unwrap_or_default()
}
/// The `/dev/net/tun` character device, as it is named on both sides.
const TUN_DEVICE: &str = "/dev/net/tun";
/// The `HostConfig` fields "VPN support" contributes: `CapAdd`, `Devices`,
/// `Sysctls` — in that order.
type VpnHostConfigParts = (
Option<Vec<String>>,
Option<Vec<bollard::models::DeviceMapping>>,
Option<HashMap<String, String>>,
);
/// The three host-config pieces a VPN client needs, or all-`None` when the
/// project has not opted in.
///
/// Returned as a triple rather than set inline so the exact shape is unit
/// testable — a container is created once, by a very long async function, and a
/// silently-dropped capability looks identical to a VPN server that is simply
/// unreachable.
///
/// All three are required together and each fails differently on its own:
/// * **`CAP_NET_ADMIN`** — without it the client cannot create an interface or
/// write a route. Docker's default bounding set grants `net_raw` but not
/// `net_admin`, which is why a client can ping but never connect.
/// * **`/dev/net/tun`** — the device is absent from a default container, so
/// there is nothing to open even with the capability. It is passed through
/// from the host rather than `mknod`-ed inside, so the kernel's `tun` module
/// backs it.
/// * **`net.ipv4.conf.all.src_valid_mark`** — WireGuard's own `wg-quick` sets
/// this, and cannot from inside a container (`/proc/sys` is read-only), so
/// its handshake packets are dropped by reverse-path filtering. Harmless for
/// OpenVPN-based clients, so it is set unconditionally with the rest.
///
/// What it costs, stated accurately: Docker does not enable user-namespace
/// remapping by default, so this is a real `CAP_NET_ADMIN` in the *initial*
/// user namespace and only the **network** namespace confines it. It cannot
/// touch the host's interfaces, but within its own namespace it can set
/// promiscuous mode and add arbitrary addresses, routes and NAT rules on the
/// shared `docker0` L2 segment — which puts sibling containers (the LiteLLM
/// gateway among them) within reach of ARP spoofing, and lets netlink trigger
/// host-kernel module auto-loading. It is also enough to flush netfilter rules
/// inside the container, so pair it with `sandbox_mode_enabled` advisedly.
/// Hence opt-in, per project, rather than on for everyone.
/// The env var `entrypoint.sh` installs and removes the `pia-vpn` skill from.
///
/// **Emitted either way, never omitted.** `~/.claude` is a persisted volume, so
/// turning the toggle off has to actively tell entrypoint to remove a skill an
/// earlier run left there, and an absent variable cannot say that. It is also
/// what stops a `=1` baked into a snapshot by `docker commit` from outliving
/// the setting — the explicit `=0` overwrites it.
///
/// Extracted for the same reason as [`vpn_host_config`]: the emitting code sits
/// in a very long function where a dropped or inverted value is invisible, and
/// `MISSION_CONTROL_ENABLED` twenty lines above shows the failure this avoids —
/// it is pushed only when true, so a snapshot's baked `=1` survives the toggle
/// going off.
fn vpn_env_var(enabled: bool) -> String {
format!("VPN_SUPPORT_ENABLED={}", u8::from(enabled))
}
fn vpn_host_config(enabled: bool) -> VpnHostConfigParts {
if !enabled {
return (None, None, None);
}
let devices = vec![bollard::models::DeviceMapping {
path_on_host: Some(TUN_DEVICE.to_string()),
path_in_container: Some(TUN_DEVICE.to_string()),
cgroup_permissions: Some("rwm".to_string()),
}];
let sysctls = HashMap::from([(
"net.ipv4.conf.all.src_valid_mark".to_string(),
"1".to_string(),
)]);
(
Some(vec!["NET_ADMIN".to_string()]),
Some(devices),
Some(sysctls),
)
}
/// Turn the daemon's device-passthrough failure into an explanation.
///
/// **This fires on `start`, not `create`.** Verified against Docker 29.7:
/// `docker create --device /dev/does-not-exist` succeeds and prints an id; the
/// device is only resolved when runc builds the container, so the failure lands
/// on the *next* call. Sysctls validate at the same point. Anything that
/// inspects only the create path will never see it — which is why both paths
/// route through here and the tests exercise the start-side string.
///
/// Unmapped, this reads as `Failed to start container: Docker responded with
/// status code 500: error gathering device information while adding custom
/// device "/dev/net/tun": no such file or directory` — a path the user will go
/// looking for on the wrong machine, since with Docker Desktop the relevant
/// host is the Linux VM rather than their own, and with nothing pointing back
/// at the switch that caused it.
///
/// Deliberately not gated on `vpn_support_enabled`: nothing else in Triple-C
/// ever asks for a device, so an error naming `/dev/net/tun` can only have come
/// from a container created with the switch on. That keeps the check usable
/// from [`start_container`], which has a container id and no project.
fn explain_container_failure(action: &str, err: &str) -> String {
let device_missing = err.contains(TUN_DEVICE)
&& (err.contains("no such file or directory")
|| err.contains("No such file or directory")
|| err.contains("error gathering device information"));
if device_missing {
return format!(
"Failed to {} container: the Docker host has no {} device, which \
\"VPN support\" requires. The host kernel needs the `tun` module \
loaded (on Docker Desktop that is the Linux VM, not your own \
machine). Turn VPN support off in Config → Runtime to start this \
project without it. Original error: {}",
action, TUN_DEVICE, err
);
}
format!("Failed to {} container: {}", action, err)
}
/// One bind mount per stored project path, skipping the rows that cannot
/// produce a usable one.
///
/// Both halves of the filter are about data that is **already on disk**, which
/// is why this is a silent skip and not a validation error. `project_commands`
/// now refuses an empty `mount_name` or `host_path` on save, and the Workspace
/// pane can no longer send a half-filled row — but neither of those reaches a
/// record written before they existed, and the two failures such a record
/// causes are not equally visible:
///
/// * An empty `host_path` becomes `{"Target":"/workspace/x","Source":""}`, and
/// the daemon answers `invalid mount config for type "bind": field Source
/// must not be empty` for the whole create. The project cannot be started or
/// recreated at all — it is bricked, and it stays bricked however carefully
/// the next save is validated. Skipping the row lets it start again, minus a
/// mount that was never going to work.
/// * An empty `mount_name` targets `/workspace/` itself, mounting the row's
/// host directory *over* the workspace volume. The daemon then creates the
/// other rows' mount points inside the user's real folder, which is a
/// directory tree appearing in their project from nowhere.
///
/// Failing louder is the wrong instinct here: the loud version is the one that
/// already happened, and it took the project down with it. See
/// `project_commands.rs`'s `check_mount_name_stays_under_workspace` for the
/// save-time half — deliberately still tolerant of an empty name there, since
/// refusing it would re-brick every project holding a legacy row.
fn project_path_mounts(paths: &[crate::models::project::ProjectPath]) -> Vec<Mount> {
paths
.iter()
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
// of `" "` is a path the daemon will happily create at the filesystem
// root — neither is what anyone typed on purpose.
.filter(|pp| {
let keep = !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty();
if !keep {
// Silence here means a folder the user configured simply does
// not appear in the container, with no error and no toast.
// Skipping is still right — the alternative is a project that
// cannot start — but it should leave a trace.
log::warn!(
"Skipping an unmountable project path row (host_path={:?}, mount_name={:?}): \
both are required. The project will start without it.",
pp.host_path,
pp.mount_name
);
}
keep
})
.map(|pp| Mount {
target: Some(format!("/workspace/{}", pp.mount_name)),
source: Some(pp.host_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
})
.collect()
}
pub async fn create_container(
project: &Project,
docker_socket_path: &str,
image_name: &str,
base_image_name: &str,
extras: CreateExtras<'_>,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
let mut env_vars: Vec<String> = Vec::new();
// Tell CLI tools the terminal supports 24-bit RGB color
env_vars.push("COLORTERM=truecolor".to_string());
// Pass host UID/GID so the entrypoint can remap the container user
#[cfg(unix)]
{
let uid = std::process::Command::new("id").arg("-u").output();
let gid = std::process::Command::new("id").arg("-g").output();
if let Ok(out) = uid {
if out.status.success() {
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !val.is_empty() {
log::debug!("Host UID detected: {}", val);
env_vars.push(format!("HOST_UID={}", val));
}
} else {
log::debug!("Failed to detect host UID (exit code {:?})", out.status.code());
}
}
if let Ok(out) = gid {
if out.status.success() {
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !val.is_empty() {
log::debug!("Host GID detected: {}", val);
env_vars.push(format!("HOST_GID={}", val));
}
} else {
log::debug!("Failed to detect host GID (exit code {:?})", out.status.code());
}
}
}
#[cfg(windows)]
{
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
}
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
// Per-project git user overrides global defaults
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
if let Some(name) = effective_git_name {
env_vars.push(format!("GIT_USER_NAME={}", name));
}
if let Some(email) = effective_git_email {
env_vars.push(format!("GIT_USER_EMAIL={}", email));
}
// Bedrock configuration
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
// AWS region: per-project overrides global
let region = if !bedrock.aws_region.is_empty() {
Some(bedrock.aws_region.clone())
} else {
global_aws.aws_region.clone()
};
if let Some(ref r) = region {
env_vars.push(format!("AWS_REGION={}", r));
}
match bedrock.auth_method {
BedrockAuthMethod::StaticCredentials => {
// Static/session credentials are NOT injected as env vars.
// They are written to ~/.aws/credentials by
// sync_bedrock_credentials() on every container
// start, so rotated/updated keys are picked up without a
// full container recreation (and never get baked into the
// snapshot image). The empty values set by the
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
// the AWS SDK, which falls through to the credentials file.
}
BedrockAuthMethod::Profile => {
// Per-project profile overrides global
let profile = bedrock.aws_profile.as_ref()
.or(global_aws.aws_profile.as_ref());
if let Some(p) = profile {
env_vars.push(format!("AWS_PROFILE={}", p));
}
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
}
BedrockAuthMethod::BearerToken => {
if let Some(ref token) = bedrock.aws_bearer_token {
env_vars.push(format!("AWS_BEARER_TOKEN_BEDROCK={}", token));
}
}
}
if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
if bedrock.disable_prompt_caching {
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
}
if let Some(ref tier) = bedrock.service_tier {
let trimmed = tier.trim();
if !trimmed.is_empty() {
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
}
}
}
}
// ── Custom-endpoint backends ─────────────────────────────────────────────
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
// id here; the model-alias vars are emitted once below, from
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
let mut alias_model: Option<String> = None;
let mut alias_haiku: Option<String> = None;
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// llama.cpp (llama-server) configuration
if project.backend == Backend::LlamaCpp {
if let Some(ref cfg) = project.llamacpp_config {
if let Some(url) = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
// llama-server only enforces an Authorization header when it was
// started with `--api-key` (default: none), so the value here is
// ignored in the common case. Claude Code still refuses to run
// against a custom base URL with no credential at all, so a
// placeholder is always sent — same trick as the Ollama branch
// above, which sends the literal "ollama".
env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string());
if let Some(model) = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config {
if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
}
if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// Model aliases — the fix for background Claude Code calls against a local
// server. Only for backends that talk to a custom endpoint: Anthropic and
// Bedrock reach servers that really do host the Anthropic model ids, so
// they keep Claude Code's own defaults. Anything not emitted here is
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
// custom endpoint clears the aliases out of the snapshot image too.
if project.backend.uses_custom_endpoint() {
for (key, value) in
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
{
env_vars.push(format!("{}={}", key, value));
}
}
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
// project). Injected *before* the neutralization pass below so that pass
// sees it as already-set; when it is absent the pass actively blanks the
// variable instead of leaving a stale one baked into the snapshot image.
let shared_claude = shared_claude_auth(project);
if let Some((ref token, _)) = shared_claude {
env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token));
log::info!(
"Injecting the shared Claude authentication token into the container for project {}",
project.id
);
}
// ── Corporate CA certificates ───────────────────────────────────────────
// Resolved here (rather than down with the mounts) so the env vars land
// *before* the neutralization pass below and are seen as already-set.
//
// A bad path is a hard error, not a warning: behind a TLS-terminating
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
// and Claude Code's own API requests — each in its own confusing way. One
// message naming the path is far kinder.
//
// The values are set here rather than exported by the entrypoint because a
// terminal session is a `docker exec`, which sees the container's
// configured env and nothing the entrypoint exported. Same lesson as
// `$BROWSER` and the URL relay shim.
let effective_ca_path =
resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path);
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
if let Some(ref ca) = resolved_ca {
log::info!(
"Mounting {} corporate CA certificate(s) from {} into project {}",
ca.cert_files.len(),
ca.host_path,
project.id
);
}
for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) {
env_vars.push(format!("{}={}", key, value));
}
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container,
// and `docker commit` copies that container's full ENV into the image. So
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
// unless we explicitly override it at create time.
//
// This pass is about *staleness*, not secrecy. It fixes the container it is
// building and does nothing to the image, so it is not — and never was —
// a defence against a credential baked into a snapshot. That is
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
// Create-time env takes precedence over image ENV, so we set every managed
// auth key the *current* backend did NOT set to an empty value, clearing the
// stale baked-in one.
const MANAGED_AUTH_KEYS: &[&str] = &[
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SSO_AUTH_REFRESH_CMD",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
// *clear* the aliases, not merely stop setting them: a stale
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
// keep pointing background calls at a model id the new backend has
// never heard of.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
// Revoking the shared token, opting a project out, or switching away
// from the Anthropic backend must *clear* this, not merely stop setting
// it — otherwise the value committed into the snapshot image keeps
// authenticating the container with a credential the user removed.
CLAUDE_OAUTH_TOKEN_ENV,
];
// Same reasoning for the CA vars — `ca_env_vars` already emits them empty
// when no CA is configured, so this list is belt-and-braces for a snapshot
// committed by a build that predates the feature.
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
.iter()
.copied()
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
.collect();
let already_set: std::collections::HashSet<String> = env_vars
.iter()
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
.collect();
for key in &managed_keys {
if !already_set.contains(*key) {
env_vars.push(format!("{}=", key));
}
}
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
if is_reserved_env_key(key) {
log::warn!("Skipping reserved env var: {}", key);
continue;
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Container timezone
if let Some(tz) = timezone {
if !tz.is_empty() {
env_vars.push(format!("TZ={}", tz));
}
}
// Mission Control env var
if project.mission_control_enabled {
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
}
env_vars.push(vpn_env_var(project.vpn_support_enabled));
// Permission mode — read by triple-c-task-runner for scheduled (headless)
// Claude Code runs. Interactive terminals get the flags directly instead.
env_vars.push(format!(
"TRIPLE_C_PERMISSION_MODE={}",
project.effective_permission_mode().as_env_value()
));
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
let combined_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
// Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
// Env-var-based settings, read directly by Claude Code. Extracted and unit
// tested for the `vpn_host_config` reason: a container is created once, by
// a very long function, and a variable emitted with the wrong value here is
// invisible until someone wonders why a switch does nothing.
env_vars.extend(claude_code_env_vars(merged_cc_settings.as_ref()));
// settings.json-based settings (applied by the entrypoint). Always emitted,
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
// *whole* managed key set, so "no settings" still has to be stated over a
// settings.json left behind on the config volume by a previous config.
// Split so the payload is safe under an older entrypoint that has never
// heard of the null-means-delete convention — see
// `split_claude_code_settings_payload`.
let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload(
&build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled),
);
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set));
env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear));
let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name}
mounts.extend(project_path_mounts(&project.paths));
// Named volume for the entire home directory — preserves ~/.claude.json,
// ~/.local (pip/npm globals), and any other user-level state across
// container stop/start cycles.
mounts.push(Mount {
target: Some("/home/claude".to_string()),
source: Some(home_volume_name(&project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// Named volume for claude config persistence — mounted as a nested volume
// inside the home volume; Docker gives the more-specific mount precedence.
mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(config_volume_name(&project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
// Per-project ssh_key_path overrides global default_ssh_key_path
let effective_ssh_path = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if let Some(ssh_path) = effective_ssh_path {
mounts.push(Mount {
target: Some("/tmp/.host-ssh".to_string()),
source: Some(ssh_path.to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
// Corporate CA certificates mount (read-only staging; the entrypoint copies
// them into /usr/local/share/ca-certificates with a `.crt` name and runs
// update-ca-certificates). Mirrors /tmp/.host-ssh and /tmp/.host-aws.
//
// A directory mounts at /tmp/.host-ca; a single file mounts at
// /tmp/.host-ca/<name>.crt so the entrypoint always sees a directory and
// the certificate keeps a recognisable name. Docker creates the parent.
if let Some(ref ca) = resolved_ca {
mounts.push(Mount {
target: Some(ca.mount_target.clone()),
source: Some(ca.host_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
// AWS config mount (read-only)
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
let should_mount_aws = if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
bedrock.auth_method == BedrockAuthMethod::Profile
} else {
false
}
} else {
false
};
// For static-credential Bedrock, sync_bedrock_credentials() is the sole
// owner of ~/.aws/credentials (it rewrites it on every start). Mounting the
// host AWS dir would make the entrypoint's `rm -rf ~/.aws; cp -a` race that
// write at startup, so we never mount it in that case — the static keys
// (+ AWS_REGION env) are self-sufficient and don't need the host config.
let is_bedrock_static = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
.unwrap_or(false);
if (should_mount_aws || aws_config_path.is_some()) && !is_bedrock_static {
let aws_dir = aws_config_path
.map(|p| std::path::PathBuf::from(p))
.or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
if let Some(ref aws_path) = aws_dir {
if aws_path.exists() {
mounts.push(Mount {
target: Some("/tmp/.host-aws".to_string()),
source: Some(aws_path.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
}
}
// Docker socket (if allowed)
if project.allow_docker_access {
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts.
let mount_source = if docker_socket_path == "//./pipe/docker_engine" {
"/var/run/docker.sock".to_string()
} else {
docker_socket_path.to_string()
};
mounts.push(Mount {
target: Some("/var/run/docker.sock".to_string()),
source: Some(mount_source),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
});
}
// Port mappings
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
for pm in &project.port_mappings {
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
exposed_ports.insert(container_key.clone(), HashMap::new());
port_bindings.insert(
container_key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(pm.host_port.to_string()),
}]),
);
}
let mut labels = HashMap::new();
labels.insert(LABEL_MANAGED.to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.llamacpp-fingerprint".to_string(), compute_llamacpp_fingerprint(project, global_llamacpp));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
// Capabilities, devices and sysctls are fixed at creation, so this is
// container state and gets the label-and-compare treatment. Written
// unconditionally (`false`, not omitted) because `docker commit` copies
// container labels onto the snapshot image: a `true` stamped once would
// otherwise ride that snapshot into every future container and make the
// switch impossible to turn back off.
labels.insert("triple-c.vpn-support".to_string(), project.vpn_support_enabled.to_string());
labels.insert("triple-c.permission-mode".to_string(),
project.effective_permission_mode().as_env_value().to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
// Written unconditionally, even when empty — `container_needs_recreation`
// is label-based and never diffs env or mounts, so without this a changed
// CA path would silently do nothing until some unrelated setting forced a
// rebuild. The fingerprint covers the certificate *bytes* as well as the
// path, so swapping a rotated CA in at the same location is caught too.
labels.insert("triple-c.ca-fingerprint".to_string(),
ca_certs::compute_ca_fingerprint(effective_ca_path));
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
labels.insert("triple-c.git-token-hash".to_string(),
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
// Rotation id, NOT the token and NOT a hash of it — labels are readable by
// anything on the host via `docker inspect`.
labels.insert("triple-c.claude-token-version".to_string(),
shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default());
// ── Base-image lineage ───────────────────────────────────────────────────
// `triple-c.create-image` is what this container was actually created
// from — the snapshot when one exists, otherwise the configured base. It is
// what `container_needs_recreation` compares against; the older
// `triple-c.image` label recorded the same thing but was compared against
// the container's *own* image, which is where it came from, so that check
// was a tautology and never fired. `triple-c.image` is still written for
// continuity with existing containers but is no longer compared.
//
// `triple-c.base-image-id` records the lineage — see `resolve_base_image_id`.
//
// All three (plus the migration marker) are written **unconditionally**,
// even when 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 value stamped once would otherwise ride the snapshot into
// every future container forever. Writing the key explicitly overrides the
// inherited one — the same defence MANAGED_AUTH_KEYS applies to env.
labels.insert(
super::migration::LABEL_CREATE_IMAGE.to_string(),
image_name.to_string(),
);
labels.insert(
super::migration::LABEL_BASE_IMAGE_ID.to_string(),
resolve_base_image_id(image_name, base_image_name).await,
);
labels.insert(
super::migration::LABEL_MIGRATION_STATE.to_string(),
String::new(),
);
// Same defence, applied to the legacy MCP shim — and here it fixes a real,
// observed bug rather than pre-empting one. `container_needs_recreation`
// recreates any container carrying a non-empty `triple-c.mcp-fingerprint`,
// but nothing has written that label since the MCP feature was removed. It
// survives only by *inheritance* from a snapshot image committed by an
// older build (one such image was found on this host with a non-empty
// value), and every recreation re-commits it — so the shim can never
// terminate and the project is recreated on every single start. Writing it
// explicitly empty makes the shim fire exactly once, which is what it was
// always meant to do.
labels.insert("triple-c.mcp-fingerprint".to_string(), String::new());
// Same defence, for the label `container/Dockerfile` now stamps on the base
// image. Docker merges an image's labels into the container it creates, and
// `docker commit` copies the container's labels onto the snapshot — so
// without this line every project snapshot would inherit
// `triple-c.base=true` from the base it descends from and claim to *be* a
// base image. Writing it explicitly empty overrides the inherited value.
labels.insert(LABEL_BASE.to_string(), String::new());
for (key, value) in extras.extra_labels {
labels.insert((*key).to_string(), (*value).to_string());
}
let (cap_add, devices, sysctls) = vpn_host_config(project.vpn_support_enabled);
let host_config = HostConfig {
mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true),
log_config: Some(capped_log_config()),
cap_add,
devices,
sysctls,
..Default::default()
};
let working_dir = if project.paths.len() == 1 {
format!("/workspace/{}", project.paths[0].mount_name)
} else {
"/workspace".to_string()
};
let config = Config {
image: Some(image_name.to_string()),
hostname: Some("triple-c".to_string()),
env: Some(env_vars),
labels: Some(labels),
working_dir: Some(working_dir),
host_config: Some(host_config),
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
tty: Some(true),
..Default::default()
};
let options = CreateContainerOptions {
name: container_name,
..Default::default()
};
let response = docker
.create_container(Some(options), config)
.await
.map_err(|e| explain_container_failure("create", &e.to_string()))?;
Ok(response.id)
}
/// The rotation policy every Triple-C container is created with.
///
/// Without this a container inherits the daemon's `json-file` default, which
/// has **no size limit at all** — `docker logs` for a container that has been
/// up for weeks is a single file that grows until the disk does not have room
/// for it. Triple-C containers are long-lived by design (stop/start, not
/// create/destroy), and the entrypoint plus anything a session leaves running
/// on stdout all land in that one file.
///
/// 10 MiB × 3 keeps roughly the last 30 MiB, which is far more scrollback than
/// anything reads, and bounds the worst case at 30 MiB per project instead of
/// unbounded.
///
/// **Deliberately not part of `container_needs_recreation`.** That check is
/// label-based, so participating would mean a new `triple-c.*` label whose only
/// effect is to recreate every existing project once — and a recreation costs a
/// `docker commit`, i.e. a permanent multi-gigabyte layer, which is the very
/// thing this whole change set exists to avoid. Containers pick the policy up
/// on their next natural recreation instead; an existing container keeps its
/// unbounded log until then, which is exactly the status quo.
fn capped_log_config() -> bollard::models::HostConfigLogConfig {
bollard::models::HostConfigLogConfig {
typ: Some("json-file".to_string()),
config: Some(HashMap::from([
("max-size".to_string(), "10m".to_string()),
("max-file".to_string(), "3".to_string()),
])),
}
}
pub async fn start_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.start_container(container_id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| explain_container_failure("start", &e.to_string()))
}
pub async fn stop_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.stop_container(
container_id,
Some(StopContainerOptions { t: 10 }),
)
.await
.map_err(|e| format!("Failed to stop container: {}", e))
}
pub async fn remove_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
log::info!(
"Removing container {} (v=false: named volumes such as claude config are preserved)",
container_id
);
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
v: false, // preserve named volumes (claude config)
force: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to remove container: {}", e))
}
/// Return the snapshot image name for a project.
pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id)
}
/// Name of the named volume mounted at `/home/claude`.
///
/// Takes the id rather than the `Project` because the disk view runs this
/// mapping backwards: it reads volume names off the daemon and has to decide
/// which project — if any — each one belongs to. See [`HOME_VOLUME_PREFIX`].
pub fn home_volume_name(project_id: &str) -> String {
format!("{}{}", HOME_VOLUME_PREFIX, project_id)
}
/// Name of the named volume mounted at `/home/claude/.claude`, nested inside
/// the home volume. This is the one holding the OAuth credential, the plugins
/// and every session transcript.
pub fn config_volume_name(project_id: &str) -> String {
format!("{}{}", CONFIG_VOLUME_PREFIX, project_id)
}
/// Prefix of [`home_volume_name`]. Split out because orphan detection scans the
/// daemon's volume list for these prefixes and strips them back to a project id.
pub const HOME_VOLUME_PREFIX: &str = "triple-c-home-";
/// Prefix of [`config_volume_name`]. See [`HOME_VOLUME_PREFIX`].
pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-";
/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
/// auth on every container start:
/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
/// latest keychain values and drop a stale `~/.aws/config` left by a prior
/// profile/SSO session, so rotated keys are picked up without recreating the
/// container.
/// - **Any other backend / auth method**: remove a stale `~/.aws/credentials`
/// written by a previous static-credential session, so the secrets don't
/// linger unused in the persistent home volume after switching away.
///
/// Both cleanups are skipped when `/tmp/.host-aws` is mounted (a global
/// `aws_config_path` is configured), since the entrypoint already refreshes
/// `~/.aws` from the host on every start in that case.
pub async fn sync_bedrock_credentials(
container_id: &str,
project: &Project,
) -> Result<(), String> {
let static_bedrock = if project.backend == Backend::Bedrock {
project
.bedrock_config
.as_ref()
.filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
} else {
None
};
let bedrock = match static_bedrock {
Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b,
_ => {
// Not static-credential Bedrock (or static selected but no key set):
// remove a stale credentials file from a previous static session.
if matches!(static_bedrock, Some(_)) {
log::warn!("Bedrock static auth selected but no AWS access key id is set");
}
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let env = vec!["HOME=/home/claude".to_string()];
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
log::warn!(
"Failed to clear stale AWS credentials in container {}: {}",
container_id,
e
);
}
return Ok(());
}
};
let key_id = bedrock.aws_access_key_id.as_deref().unwrap_or("");
let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
// Pass secrets via the exec environment, then have the shell write them to
// the file. This keeps them out of the process argv (visible via `ps`).
let mut env = vec![
"HOME=/home/claude".to_string(),
format!("TC_AWS_KEY_ID={}", key_id),
format!("TC_AWS_SECRET={}", secret),
];
if let Some(token) = bedrock.aws_session_token.as_deref() {
if !token.is_empty() {
env.push(format!("TC_AWS_TOKEN={}", token));
}
}
// umask 077 + explicit chmod guarantees 0600. The session-token line is only
// emitted when the variable is non-empty.
//
// We also remove a stale ~/.aws/config left over from a previous
// profile/SSO session on this project (the home volume persists across
// backend switches), so its sso_session/profile settings don't shadow the
// static [default] credentials. This is skipped when /tmp/.host-aws is
// mounted (a global aws_config_path is configured) — in that case the
// entrypoint already refreshes ~/.aws from the host on every start and the
// config is intentional.
let script = r#"set -e
umask 077
mkdir -p "$HOME/.aws"
if [ ! -d /tmp/.host-aws ] && [ -f "$HOME/.aws/config" ]; then
rm -f "$HOME/.aws/config"
fi
{
printf '[default]\n'
printf 'aws_access_key_id=%s\n' "$TC_AWS_KEY_ID"
printf 'aws_secret_access_key=%s\n' "$TC_AWS_SECRET"
if [ -n "${TC_AWS_TOKEN:-}" ]; then
printf 'aws_session_token=%s\n' "$TC_AWS_TOKEN"
fi
} > "$HOME/.aws/credentials"
chmod 600 "$HOME/.aws/credentials""#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let (output, exit_code) =
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
.await
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
if exit_code != 0 {
return Err(format!(
"Writing AWS credentials into container failed (exit {}): {}",
exit_code,
output.trim()
));
}
log::info!("Wrote Bedrock static credentials into container {}", container_id);
Ok(())
}
/// Paths deleted from a container's writable layer immediately before
/// [`commit_container_snapshot`] runs.
///
/// ## Why this exists
///
/// Every recreation commits the container, and a commit **stacks a new layer**
/// on top of the previous snapshot — it never rewrites one. Deleting a file
/// after it has been committed does not give the bytes back; it writes a
/// whiteout entry and the original bytes stay in the layer below, forever. One
/// project was measured carrying 14 stacked commit layers and ~5.1 GB above its
/// base image, and 24 different conditions trigger a recreation, so changing a
/// single settings field costs a multi-gigabyte layer that nothing can reclaim.
///
/// The only moment the bytes are still free to drop is *before* the commit that
/// captures them. Measured on one container's 4.48 GB pending writable layer:
/// 3.0 GB of agent scratchpad under `/tmp/claude-*`, the drag-and-drop staging
/// area (up to 256 MiB per dropped file, and nothing in the app ever removes
/// one), one PNG per pasted image, and the apt lists/cache/logs left by every
/// runtime `apt-get install` the browser-view installer and the Playwright
/// healer run — none of which has an `apt-get clean` behind it.
///
/// ## Why a hardcoded list and not a heuristic
///
/// A snapshot is the user's system layer: their packages, their `/opt`, their
/// `/var/lib/postgresql`. Nothing here may guess. Every entry is an absolute
/// path anchored to a directory Triple-C or a package manager owns:
///
/// * `/workspace/{mount_name}` subtrees are **host bind mounts** — the user's
/// real project directories. No entry may ever reach one, which is why no
/// pattern here starts with `/workspace`.
/// * The only bind mounts under `/tmp` are `/tmp/.host-ca` and `/tmp/.host-aws`
/// (both read-only). A leading-dot name is not matched by a shell glob, and
/// none of these patterns share their prefix.
/// * The apt entries keep their parent directory and remove only its contents
/// (`lists/*`, `archives/*.deb`, `apt/*`); `apt-get` is unhappy when the
/// directories themselves are missing.
///
/// ## Why a safe-looking *pattern* is not enough (C1)
///
/// The list used to be the whole of the defence, and that was wrong. These
/// patterns are expanded by `/bin/sh` inside the container running as **root**,
/// and for an entry ending `/*` the parent is a *path component*: both the glob
/// expansion and the `rm -rf` that follows resolve it. The agent in the
/// container has passwordless sudo, so anything able to run
/// `ln -s /workspace/myproject /var/log/apt` turns the next commit into a
/// recursive delete of the user's real files **on the host**. That was
/// reproduced end to end against a live container — a bind mount emptied by a
/// scrub whose path list named no `/workspace` anywhere.
///
/// (The entries whose glob is in the last component are the benign case: there
/// the *match* is the symlink, and `rm -rf -- link` unlinks the link and stops.
/// The distinction is not one a reviewer should have to make per entry, so the
/// script defends both alike.)
///
/// So these entries are only half the contract. The other half is
/// [`snapshot_scrub_script`], which validates the parent directory — not
/// reached through a symlink, not on another filesystem, inside
/// [`SCRUB_CONTAINMENT_PREFIXES`] — before it deletes anything inside it. That
/// is also why every entry here must keep its glob in the **final component**:
/// the parent has to be literal for the script to be able to check it at all. A
/// test enforces it.
///
/// A unit test also pins the list itself, because the blast radius of a wrong
/// entry here is a user's data and the code that consumes it is a shell string.
pub(crate) const SNAPSHOT_SCRUB_PATHS: &[&str] = &[
// Agent scratchpads. The user's global CLAUDE.md instructs every agent to
// put temporary files under a scratchpad directory in /tmp, so this is
// where a long-running project's writable layer actually goes.
//
// Not age-limited, and worth being explicit about why that is only *just*
// safe: the scrub runs as root against a container that is still running,
// so a live Claude Code session or a `triple-c-scheduler` task writing here
// has its scratchpad pulled out from under it mid-write. Both callers stop
// the container within a line or two — `start_project_container` in
// `project_commands.rs` stops and removes it, `migrate_project_to_base`
// stops it — so the process that would notice is about to be killed anyway.
// That is a property of those two call sites and not of this entry: a third
// caller scrubbing a container it means to keep running would be corrupting
// a live session, and would need to age-limit this the way the two below
// are.
"/tmp/claude-*",
// Files drag-dropped into a terminal, staged by
// `commands/terminal_commands.rs` at up to 256 MiB each, and one PNG per
// pasted image from the same module. Nothing else in the repo deletes
// either, so leaving them out of this list restores unbounded growth — but
// they are the user's *own* files, and often the only copy inside the
// container of something handed to the agent seconds ago by a path that is
// still sitting in the conversation. Scrubbing them unconditionally meant
// "drop a file, change any of the 24 settings that trigger a recreation,
// lose it silently". Both are therefore age-limited rather than removed —
// see [`SCRUB_MIN_AGE_DAYS`].
"/tmp/triple-c-drops/*",
"/tmp/clipboard_*.png",
// Runtime apt debris. `browser_view/install.rs` and
// `container/triple-c-playwright-heal` both run `apt-get install` inside a
// live container without an `apt-get clean` after it.
"/var/lib/apt/lists/*",
"/var/cache/apt/archives/*.deb",
"/var/log/apt/*",
"/var/log/dpkg.log",
];
/// Entries of [`SNAPSHOT_SCRUB_PATHS`] that are only deleted once a match's own
/// mtime is older than the given number of days. Anything not named here is
/// deleted whenever it is present.
///
/// Two weeks is chosen against the thing that goes wrong: a recreation can
/// happen seconds after a drop, and no conversation is still quoting a path it
/// was given a fortnight ago. It is a compromise, not a proof — the alternative
/// of dropping these patterns entirely would be silent unbounded growth in a
/// directory nothing else ever cleans.
const SCRUB_MIN_AGE_DAYS: &[(&str, u32)] = &[
("/tmp/triple-c-drops/*", 14),
("/tmp/clipboard_*.png", 14),
];
/// The only directory trees [`snapshot_scrub_script`] will operate in, checked
/// against the *resolved* parent directory at run time inside the container.
///
/// Deliberately **not** derived from [`SNAPSHOT_SCRUB_PATHS`]: it is the
/// backstop for the case where that list is itself wrong. An entry added under
/// `/workspace`, `/home/claude` or `/etc` fails this check and deletes nothing,
/// however plausible it looked in review.
const SCRUB_CONTAINMENT_PREFIXES: &[&str] = &["/tmp", "/var/log", "/var/lib/apt", "/var/cache/apt"];
/// Marker the scrub script prints so the byte total can be read back out of the
/// exec's interleaved stdout/stderr.
const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED ";
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
/// cannot run at all, followed by what was missing.
///
/// H3: the script needs five external tools, the root filesystem's device id,
/// and an `rm` that honours `--one-file-system` — seven prerequisites in all,
/// and on a base image that has none of them where it looked it used to run its
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
/// number indistinguishable from an honest "there was nothing to take". A scrub
/// that could not run must never read as one that ran, so it says so in a line
/// the caller can tell apart, and prints no total at all.
///
/// Deliberately **not** a prefix of [`SCRUB_MARKER`] and not prefixed by it, so
/// `parse_scrub_total` can never mistake one for the other.
const SCRUB_UNAVAILABLE_MARKER: &str = "###TRIPLE-C-SCRUB-UNAVAILABLE ";
/// The external tools [`snapshot_scrub_script`] probes for before it will
/// delete anything, in the order the probe names them.
///
/// Named once rather than spelled into the script text, because
/// [`parse_scrub_unavailable`] has to be able to tell a missing *tool* from the
/// other two prerequisites — the root device id, and an `rm` that takes
/// `--one-file-system` — so that the log line reads as a sentence rather than
/// as a list with `root-device-id` wedged into it.
const SCRUB_PREREQ_TOOLS: &[&str] = &["stat", "rm", "du", "cut", "find"];
/// Environment the scrub exec sets explicitly, rather than inheriting.
///
/// The exec inherits the container's configured env, and a project's *custom*
/// env vars are part of that: `is_reserved_env_key` reserves the `ANTHROPIC_`,
/// `AWS_`, `GIT_`, `HOST_` and `TRIPLE_C_` families and a handful of exact
/// names, none of which is `LD_PRELOAD`. So a custom env var naming a shared
/// object inside the container's *persisted home volume* injects code into
/// every tool the scrub runs — as **root**, since that is the user the exec is
/// created as — and decides the answer to checks 3 and 4 without ever touching
/// `PATH`. `docker commit` bakes env into the snapshot, so it rides along too.
///
/// Blanking them in the exec closes that without breaking anything: nothing
/// legitimate preloads into `/bin/sh`, `stat`, `du`, `cut`, `find` or `rm`, and
/// this env applies to the scrub exec **only** — a terminal session, which is a
/// different exec, is untouched. Verified against Engine 29.7 that a
/// `docker exec -e LD_PRELOAD=` overrides a container-level `LD_PRELOAD`.
///
/// `LD_LIBRARY_PATH` is here for the same reason as `LD_PRELOAD`: it is
/// searched ahead of the cache for every `DT_NEEDED` library, so a planted
/// `libselinux.so.1` is as good as a preload.
///
/// M2: [`SCRUB_BOOTSTRAP`] now starts the shell that runs the script under
/// `env -i`, so none of these three reaches the script's own tools whatever
/// this list says. They are kept because they still reach the *two processes
/// in front of it* — the `/bin/sh` that runs the bootstrap and the `env` it
/// calls — and an `env` with a preload in it is an `env` that can hand its
/// child any environment it likes. Blanking here and emptying there are the
/// same defence applied at the two points it has to hold at.
const SCRUB_EXEC_ENV: &[&str] = &["LD_PRELOAD=", "LD_AUDIT=", "LD_LIBRARY_PATH="];
/// The `/bin/sh -c` program that starts the real scrub, with
/// [`snapshot_scrub_script`] passed to it as `$1` rather than spliced into it.
///
/// ## Why the script is not simply the exec's command (M2)
///
/// `docker exec` cannot *replace* a container's environment, only add to and
/// override it — which is why [`SCRUB_EXEC_ENV`] works by naming keys. The env
/// a project carries is therefore inherited by whatever shell the exec starts,
/// and a shell reads more out of its environment than variables.
///
/// `bash` imports **functions** from the environment: an env var named
/// `BASH_FUNC_stat%%` becomes a shell function called `stat`. Function lookup
/// happens *before* `PATH` is ever consulted, so resetting `PATH` does not
/// touch it, and `command -v` reports a function as found — so a planted
/// function passes the prerequisite probe and then answers the containment
/// checks. Measured against `bash` 5.2 on `ubuntu:24.04`, an environment
/// carrying `BASH_FUNC_stat%%` had `stat -c %d` return a constant of its
/// choosing while `command -v stat` said the tool was present.
///
/// Every in-shell answer to that was tried and measured, and each one is
/// itself importable: `unset -f stat` is defeated by `BASH_FUNC_unset%%`,
/// `command stat` by `BASH_FUNC_command%%`, and — this is what settles it —
/// `[`, `test`, `pwd` and `cd` import just as readily, which is checks 0, 1
/// and 2 of the containment guarantee, not merely the tools. There is no
/// subset of the script that can be written in shell and still be trusted
/// inside a shell that has already imported the attacker's functions.
///
/// So the script is not run by that shell. This bootstrap is, and everything
/// it uses is either a reserved word (`case`, `esac`), a parameter expansion,
/// or a command word containing a `/` — and `bash` refuses to import a
/// function whose name contains a `/`, verified on the same image. It hands
/// the script to a **second** `/bin/sh` started by `env -i`, which has no
/// environment at all: no `BASH_FUNC_*`, no `LD_*`, no `ENV`/`BASH_ENV`, and
/// no `PATH` but the one named here. The inner shell's builtins are its own
/// again, and the script does not have to care what `/bin/sh` is.
///
/// Measured on an `ubuntu:24.04` with `/bin/sh -> bash`, a project env var of
/// `BASH_FUNC_stat%%=() { echo 1; }` set on the container the way a custom env
/// var is, and a named volume mounted **at** the match `/tmp/claude-x` — the
/// position checks 3 and 4 exist for:
///
/// * `/bin/sh -c <script>`, which is what the exec used to be, emptied the
/// volume and printed `###TRIPLE-C-SCRUBBED 306565`;
/// * `/bin/sh -c <bootstrap> triple-c-scrub <script>` left the volume intact,
/// reclaimed the real scratchpad beside it, and printed `65536`.
///
/// The same script, unchanged, in both runs.
///
/// `env` is not assumed to be usr-merged, for the reason spelled out under
/// [`snapshot_scrub_script_under`]: `/usr/bin/env` first, `/bin/env` on a
/// literal 127, which is the pair that covers `ubuntu`, `debian`, `alpine`,
/// `busybox` and `node:*-slim` (checked). An image with neither runs nothing,
/// prints no marker, and is reported as [`ScrubOutcome::Failed`] — loud, and
/// preferable to falling back to a shell that would run the script in the
/// environment this exists to keep away from it.
///
/// Passing the script as `$1` rather than interpolating it into a quoted inner
/// command string is not a style choice: the script contains single quotes in
/// every `scrub_in` call, and a nested quoting bug in a string that runs
/// `rm -rf` as root is the whole class of defect this module keeps having.
const SCRUB_BOOTSTRAP: &str = concat!(
r#"/usr/bin/env -i PATH=/usr/bin:/bin /bin/sh -c "$1"; "#,
r#"case $? in 127) /bin/env -i PATH=/usr/bin:/bin /bin/sh -c "$1" ;; esac"#,
);
/// The argv the scrub exec is created with: [`SCRUB_BOOTSTRAP`], then the
/// `$0` a diagnostic would name the shell by, then the script itself as `$1`.
fn scrub_exec_cmd(script: &str) -> Vec<String> {
vec![
"/bin/sh".to_string(),
"-c".to_string(),
SCRUB_BOOTSTRAP.to_string(),
"triple-c-scrub".to_string(),
script.to_string(),
]
}
/// Split a [`SNAPSHOT_SCRUB_PATHS`] entry into the literal directory it is
/// anchored to and the glob to expand inside it.
///
/// `None` for anything that is not an absolute path with a non-empty final
/// component — which a test rules out, but the script generator must not have
/// to trust that, since what it emits runs as root.
fn split_scrub_pattern(pattern: &str) -> Option<(&str, &str)> {
let (dir, glob) = pattern.rsplit_once('/')?;
if !pattern.starts_with('/') || glob.is_empty() {
return None;
}
// A top-level entry such as `/dpkg.log` leaves an empty parent; anchor it.
Some((if dir.is_empty() { "/" } else { dir }, glob))
}
/// The `/bin/sh` program run inside the container to perform the scrub.
///
/// ## Never run the string this returns anywhere but inside a container
///
/// It is anchored at `/`, and its first pattern is `/tmp/claude-*`. A Triple-C
/// development container keeps every agent's scratchpad — working files,
/// backups of the very sources being edited, task output — under
/// `/tmp/claude-<uid>/`, which that glob matches exactly. Running this as root
/// on a development machine or in the dev container deletes all of it, reports
/// a healthy byte total, and leaves nothing to say what happened.
///
/// Anything that wants to *exercise* the scrub outside a container must use
/// [`snapshot_scrub_script_under`] with a freshly generated, uuid-named
/// throwaway root, and that applies to negative controls too: a control that
/// plants a symlink to show the vulnerable version follows it must point that
/// symlink **inside the same throwaway root**, never at a path derived from
/// the crate, the repository or the environment. The whole point of those
/// controls is that they delete what they are aimed at.
pub(crate) fn snapshot_scrub_script() -> String {
snapshot_scrub_script_under("")
}
/// [`snapshot_scrub_script`] with every absolute path re-anchored under `root`.
///
/// ## What the script does
///
/// One `scrub_in <pattern> <min-age-days, or `-`>` call per entry in
/// [`SNAPSHOT_SCRUB_PATHS`], with the pattern single-quoted so it reaches the
/// function unexpanded — unquoted, `/bin/sh` would expand the glob against the
/// script's own working directory before `scrub_in` ever ran. The function
/// splits it into parent and glob (`${1%/*}` / `${1##*/}`, which is why the
/// glob must live in the final component), and expands the glob only once the
/// working directory *is* the validated parent. Quoted everywhere it is used,
/// so a filename containing whitespace stays one argument; an unmatched glob
/// expands to itself and is skipped by the existence guard.
///
/// Passing the whole pattern rather than the two halves also keeps each entry
/// readable verbatim in the generated script, so a test can assert the script
/// names this list rather than a second forked copy of it.
///
/// ## The containment guarantee (C1)
///
/// `scrub_in` is the only place in the script that deletes anything, and it
/// does so only after five checks. The first four are about the parent
/// directory, in order:
///
/// 0. `cd -P` into the parent **first**. Everything after that is relative to
/// the inode that gets validated, so re-pointing the *path* afterwards
/// cannot redirect the `rm`. A working directory is the only TOCTOU-free
/// handle `/bin/sh` offers.
/// 1. `pwd -P` — the fully resolved path — must equal what was asked for.
/// A symlinked component anywhere in the parent lands somewhere else, and
/// this is what catches `ln -s /workspace/myproject /var/log/apt`.
/// 2. The resolved path must be inside [`SCRUB_CONTAINMENT_PREFIXES`]. A
/// symlink is not the only way to name the wrong directory; this refuses to
/// operate outside the trees the scrub owns whatever the path list says.
/// 3. It must be on the same filesystem as the root. Check 1 does not see a
/// *bind mount*, which is not a symlink — but under `overlay2` a bind mount
/// and a named volume each have a different `st_dev` from the overlay, and
/// neither is part of the writable layer the commit is about to capture. So
/// anything on another device is both dangerous to delete and pointless to.
///
/// The fifth is about each *match*, and it is the one the parent checks cannot
/// stand in for:
///
/// 4. Every expansion of the glob must itself be on the root's filesystem, or
/// it is skipped. Since the parent has already been proved to be, that is a
/// "the match is not a mount point" test — the device of a directory differs
/// from its parent's precisely when something is mounted on it.
///
/// **On `overlay2`, which is what these were measured against.** The overlay
/// synthesises its own `st_dev`, so *every* mount into the container differs
/// from it and checks 3 and 4 catch all of them. Under the `vfs` graph
/// driver there is no overlay: the container root is a plain directory tree
/// on the host filesystem, so a bind mount of a host directory that happens
/// to live on that same filesystem reports the *same* `st_dev` and these two
/// checks see nothing. Checks 1 and 2 — resolved path, and containment in
/// [`SCRUB_CONTAINMENT_PREFIXES`] — are what still hold there, and they are
/// why a `vfs` host is a narrower gap rather than an open one: the mount has
/// to be planted inside `/tmp`, `/var/log`, `/var/lib/apt` or
/// `/var/cache/apt` and match one of the seven globs to be reached at all.
/// Do not describe the device test as a mount test without that
/// qualification.
///
/// Checks 03 validate the *parent*, and `rm --one-file-system` compares
/// against the device of **its own command-line argument** — so when the
/// argument *is* the mount root, `rm` is inside the mount already and
/// deletes everything under it. Verified in real containers against
/// `docker run -v vol:/tmp/claude-x`: mounted at the parent (`/var/log/apt`)
/// was refused, mounted one level below the match
/// (`/tmp/claude-x/inner`) was refused **on the strength of the flag
/// alone**, and mounted *as* the match (`/tmp/claude-x`) emptied the volume.
/// That middle case is why the flag is a prerequisite rather than an
/// option — see the section on M1 below. In production that mount is the
/// user's own project directory, because a mount name of `../tmp/claude-x`
/// reaches it: the daemon normalises the `/workspace/{mount_name}` target
/// built at [`create_container`] straight back to `/tmp/claude-x`.
///
/// `stat` is not asked to dereference: a *symlinked* match reports the
/// link's own device, so it still passes and `rm -rf -- link` unlinks it and
/// stops — which is the behaviour the entries whose glob is in the last
/// component have always relied on.
///
/// Inside the loop, an expansion carrying a path separator means the list has
/// changed shape and is skipped, and `rm --one-file-system` refuses to recurse
/// across a mount planted *below* a match — the one mount position none of the
/// five checks above can see, because it is not the parent and it is not the
/// match.
///
/// ## Why `--one-file-system` is a prerequisite and not an option (M1)
///
/// It used to be probed into a variable:
///
/// ```sh
/// rmopt=;
/// rm --one-file-system --help >/dev/null 2>&1 && rmopt=--one-file-system;
/// rm -rf $rmopt -- "$p";
/// ```
///
/// which fails **open**. `busybox`'s `rm` answers `unrecognized option
/// '--one-file-system'` and exits 1 for that probe, so on any
/// `ImageSource::Custom` busybox- or toybox-derived image `$rmopt` was empty
/// and `rm -rf` ran as root with no cross-filesystem bound at all — and
/// reported the result as a completed `Reclaimed(n)`.
///
/// Measured end to end, `busybox:latest` (BusyBox 1.38), a named volume
/// mounted one level below the match at `/tmp/claude-x/inner`:
///
/// * the probed shape emptied the volume and printed
/// `###TRIPLE-C-SCRUBBED 102423`;
/// * this shape prints `###TRIPLE-C-SCRUB-UNAVAILABLE rm-one-file-system` and
/// removes nothing at all.
///
/// And on `ubuntu:24.04` (GNU coreutils 9.4), same mount, to show the flag is
/// honoured rather than merely accepted: with `--one-file-system` deleted from
/// the one `rm` the volume was emptied and `306565` reported, with it the
/// volume was untouched and the figure was `65536` — exactly the debris that
/// really was next to the mount, so the byte accounting follows the flag too.
///
/// It is one of the seven prerequisites now, and an image without it is
/// [`SCRUB_UNAVAILABLE_MARKER`] rather than a scrub that runs unguarded. That
/// is a real cost — an Alpine or busybox base image stops being scrubbed and
/// keeps its debris — and it is the cheaper of the two: declining costs disk,
/// proceeding costs the mount.
///
/// The probe is `rm --one-file-system -f -- ''` rather than `--help`. It is
/// the same question asked of the code path that matters — option parsing
/// followed by an actual run — where `--help` only asks whether a usage
/// message can be printed; and the empty operand is the one argument no
/// filesystem can ever name, so a probe that *does* run cannot remove
/// anything. `-f` makes the resulting `ENOENT` an exit of 0, on GNU
/// `coreutils` and by POSIX both.
///
/// What it cannot check is that the flag is *honoured* rather than merely
/// accepted; that needs a real mount, and it is covered by the container runs
/// recorded above rather than by the probe.
///
/// ## How the byte total is counted
///
/// `du` before the `rm`, `du` again after it if anything is still there, and
/// the difference is what was reclaimed. The obvious form —
/// `rm … && acc=$((acc + sz))` — was wrong in exactly the case
/// `--one-file-system` exists for: `rm` walking into a match that has a mount
/// planted *below* it removes everything else and still exits non-zero, so the
/// whole subtree's size was dropped from the total rather than the part that
/// survived. Under-reporting a scrub is the same class of quiet as reporting
/// zero for one that never ran. The second `du` only runs when the path is
/// still present, i.e. on the failure path, so the ordinary case pays nothing;
/// the figure is clamped at the pre-`rm` size so a directory something else is
/// concurrently growing cannot make it negative.
///
/// ## Why `PATH` is reset, and why the tools are *not* named absolutely (H3)
///
/// The first statement in the script sets `PATH={root}/usr/bin:{root}/bin` and
/// exports it, before any external tool runs. That is the whole shim defence.
/// The image's `PATH` starts
/// `/home/claude/.claude/bin:/home/claude/.local/bin:/home/claude/.cargo/bin`,
/// all of which live in the container's *persisted home volume* and are
/// writable by the agent — so a three-line `stat` on the front of it decides
/// the answer to checks 3 and 4. That was reproduced: with such a shim planted,
/// a volume mounted at `/var/log/apt` — refused by an unshimmed scrub — had its
/// entire contents deleted. The shim lives in the home volume; it cannot live
/// in `/usr/bin` or `/bin`, which uid 1000 cannot write. Resetting `PATH` to
/// exactly those two directories therefore defeats *a shim on `PATH`* as
/// completely as spelling `/usr/bin/stat` out does, and buys the portability
/// that spelling it out gives away.
///
/// M2: that sentence used to stop one clause earlier, at "defeats it just as
/// completely", and read as though the reset settled command lookup. It does
/// not. `PATH` is the **last** step of lookup: a shell tries functions, then
/// builtins, then `PATH`, and `command -v` reports a function as found — so a
/// planted shell function is invisible to everything in this section and
/// passes the tool probe below into the bargain. Nothing written in this
/// script can fix that, because the shell that would run the fix has already
/// imported the function. It is fixed one level up, by [`SCRUB_BOOTSTRAP`],
/// which gives the shell that runs this script no environment to import from.
///
/// Spelling them out as well was strictly *worse*, and it is what H3 was.
/// `/usr/bin/stat` is not where coreutils live on every base image — Settings →
/// Docker → **Custom** accepts any image, and on Alpine `stat` and `rm` are in
/// `/bin`. There, `/usr/bin/stat` is simply absent, `rootdev` comes back empty,
/// and `[ -n "$rootdev" ] || exit 0` fires for all seven patterns. Measured on
/// one seeded tree: the absolute-path script reported
/// `###TRIPLE-C-SCRUBBED 0` and left every planted file in place, where the
/// `PATH`-based one reclaimed 73738 bytes. Failing closed is correct; failing
/// closed while printing a number that reads as success is not, which is why
/// the tool probe and [`SCRUB_UNAVAILABLE_MARKER`] exist below.
///
/// So: the six tools are named bare and resolved through a `PATH` the script
/// controls, and the script refuses to delete anything at all unless
/// `command -v` finds every one of them and the root device id reads back as a
/// number. `find` is required along with the rest even though its absence would
/// only disable the age gate, because "ran with one hand tied" is exactly the
/// half-working state this whole section exists to stop being silent.
///
/// This does not defend against something that can write `/usr/bin` or `/bin`
/// itself, which the agent's passwordless sudo can — and note what that is
/// *not*: it is not a gap that a container restart closes. A `sudo`-written
/// `/usr/bin/stat` lands in the writable layer, which is precisely what the
/// `docker commit` this runs in front of is about to capture, so it survives
/// into the snapshot and into every container made from it. What the reset
/// closes is the unprivileged half — the three home-volume directories on the
/// front of the image's `PATH`, which the agent writes without `sudo` and
/// which no restart clears either. Both halves persist; only one of them needs
/// a privilege the scrub could not have defended against anyway.
///
/// See [`SCRUB_EXEC_ENV`] for the `LD_PRELOAD` half of the same question and
/// [`SCRUB_BOOTSTRAP`] for the shell-function half, neither of which `PATH`
/// does anything about.
///
/// ## Why every line ends in `;`, and why there are no `#` comments
///
/// A self-terminating statement per line, and no comments, is what makes the
/// script safe to join onto one line: any embedder that folds it with spaces
/// gets a join rather than a rewrite. That property was learnt the hard way —
/// an earlier version's folded form was a `"do" unexpected` syntax error, so
/// the scrub ran not at all — and it is kept even though the folding caller is
/// gone, because a script that survives being flattened is the cheap invariant
/// and re-learning it is not.
///
/// ## Why `root` exists
///
/// `""` in production, which leaves the paths exactly as written. A non-empty
/// root lets the real generated script be run by a real `/bin/sh`, with real
/// symlinks planted in it, inside a throwaway directory tree — because
/// containment is a *runtime* property and the test this replaces
/// (`!dockerfile.contains("/workspace")`) was a substring check over the script
/// text that the exploitable version passed.
///
/// The tool paths are re-anchored with everything else, so a test root can put
/// its own `usr/bin/stat` where the script will look. That is how check 4 is
/// exercised without a mount: no unprivileged test can create a device
/// boundary (this container cannot even `unshare -Urm`), but a `stat` that
/// reports a foreign device for one directory is what the kernel would report
/// for a mount point, and the real boundary is covered by the container runs
/// recorded above.
fn snapshot_scrub_script_under(root: &str) -> String {
let calls: String = SNAPSHOT_SCRUB_PATHS
.iter()
.filter_map(|pattern| {
// Validate the shape here even though the shell re-derives it: an
// entry the script could not split is one it must not be handed.
split_scrub_pattern(pattern)?;
let age = SCRUB_MIN_AGE_DAYS
.iter()
.find(|(p, _)| p == pattern)
.map(|(_, days)| days.to_string())
.unwrap_or_else(|| "-".to_string());
Some(format!("scrub_in '{root}{pattern}' '{age}';\n"))
})
.collect();
let allowed: String = SCRUB_CONTAINMENT_PREFIXES
.iter()
.map(|p| format!("{root}{p}|{root}{p}/*"))
.collect::<Vec<_>>()
.join("|");
format!(
r#"PATH={root}/usr/bin:{root}/bin; export PATH;
total=0;
missing=;
for t in {tools}; do command -v "$t" >/dev/null 2>&1 || missing="$missing $t"; done;
command -v rm >/dev/null 2>&1 && {{ rm --one-file-system -f -- '' >/dev/null 2>&1 || missing="$missing rm-one-file-system"; }};
rootdev=$(stat -c %d '{root}/' 2>/dev/null);
case "$rootdev" in ''|*[!0-9]*) rootdev=; missing="$missing root-device-id" ;; esac;
[ -z "$missing" ] || {{ echo "{unavailable}$missing"; exit 0; }};
scrub_in() {{
n=$(
d=${{1%/*}}; [ -n "$d" ] || d=/;
g=${{1##*/}};
cd -P "$d" 2>/dev/null || exit 0;
[ "$(pwd -P)" = "$d" ] || exit 0;
case "$d" in {allowed}) ;; *) exit 0 ;; esac;
[ -n "$rootdev" ] || exit 0;
[ "$(stat -c %d . 2>/dev/null)" = "$rootdev" ] || exit 0;
acc=0;
for p in $g; do
case "$p" in */*|.|..) continue ;; esac;
{{ [ -e "$p" ] || [ -L "$p" ]; }} || continue;
[ "$(stat -c %d -- "$p" 2>/dev/null)" = "$rootdev" ] || continue;
[ "$2" = "-" ] || [ -n "$(find "./$p" -maxdepth 0 -mtime +"$2" -print 2>/dev/null)" ] || continue;
sz=$(du -sb -- "$p" 2>/dev/null | cut -f1);
case "$sz" in ''|*[!0-9]*) sz=0 ;; esac;
rm --one-file-system -rf -- "$p" 2>/dev/null;
rest=0;
{{ [ -e "$p" ] || [ -L "$p" ]; }} && rest=$(du -sb -- "$p" 2>/dev/null | cut -f1);
case "$rest" in ''|*[!0-9]*) rest=0 ;; esac;
[ "$rest" -le "$sz" ] || rest=$sz;
acc=$((acc + sz - rest));
done;
echo "$acc";
);
case "$n" in ''|*[!0-9]*) n=0 ;; esac;
total=$((total + n));
}};
{calls}echo "{marker}$total";
exit 0
"#,
root = root,
allowed = allowed,
calls = calls,
tools = SCRUB_PREREQ_TOOLS.join(" "),
marker = SCRUB_MARKER,
unavailable = SCRUB_UNAVAILABLE_MARKER,
)
}
/// Parse the byte total the scrub script reports. Returns `None` when the
/// marker is absent, which is how a container that never ran the script (or a
/// `sh` that died early) is told apart from one that reclaimed nothing.
///
/// A script that decided it could not run prints [`SCRUB_UNAVAILABLE_MARKER`]
/// and no total at all, so this returns `None` for that too — but callers must
/// ask [`parse_scrub_unavailable`] **first**, because "could not run" and
/// "printed nothing recognisable" deserve different words in the log.
fn parse_scrub_total(output: &str) -> Option<u64> {
output
.lines()
.rev()
.find_map(|line| line.trim().strip_prefix(SCRUB_MARKER)?.trim().parse().ok())
}
/// What the scrub script said was missing, if it declined to run at all.
///
/// H3: the failure this exists for is silent by construction — a scrub with no
/// usable `stat` deletes nothing and, before this, printed the same
/// `###TRIPLE-C-SCRUBBED 0` a healthy container with nothing to clean prints.
/// The script now says which tools it could not find; this reads that back so
/// [`ScrubOutcome::Unavailable`] can carry it into the log.
fn parse_scrub_unavailable(output: &str) -> Option<String> {
output.lines().rev().find_map(|line| {
let missing = line.trim().strip_prefix(SCRUB_UNAVAILABLE_MARKER)?.trim();
if missing.is_empty() {
return Some("the image is missing the tools the scrub needs".to_string());
}
// Three different things can be missing and only one of them is a file
// on a path, so they cannot share a sentence. M1 added the third and
// it is the one most likely to be read by somebody who does not know
// the history, so it says what the flag is *for* rather than naming it.
let (tools, others): (Vec<&str>, Vec<&str>) = missing
.split_whitespace()
.partition(|t| SCRUB_PREREQ_TOOLS.contains(t));
let mut parts = Vec::new();
if !tools.is_empty() {
parts.push(format!(
"has no usable {} on /usr/bin or /bin",
tools.join(", ")
));
}
parts.extend(others.into_iter().map(|other| match other {
"rm-one-file-system" => "has an `rm` that does not take `--one-file-system`, \
which is the only thing bounding a recursive delete run as root"
.to_string(),
"root-device-id" => {
"will not say which device its root filesystem is on".to_string()
}
other => format!("is missing {}", other),
}));
Some(format!("the image {}", parts.join("; it ")))
})
}
/// How long [`scrub_writable_layer`] waits for its exec before the commit goes
/// ahead without it.
///
/// The scrub is a `du -sb` plus an `rm -rf` over a tree a running agent may
/// still be writing to, and it sits on the critical path of every recreate and
/// every migration with the UI parked on "Saving container state…" and no way
/// to cancel. Two minutes is an order of magnitude more than the measured cost
/// on a 4.48 GB layer, and far less than the point where a user force-quits.
const SCRUB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// What [`scrub_writable_layer`] actually did.
///
/// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus
/// [`ScrubOutcome::Failed`]: "there was nothing to exec into" is routine, while
/// "the exec ran and broke" is the one case worth a warning in the log.
/// `#[must_use]` because the variants mean different things to a caller and
/// three of the four are easy to ignore by accident: a `Failed` swallowed at a
/// call site is exactly how a scrub that stopped working would stay quiet.
#[must_use = "a scrub that was skipped, timed out or failed reads the same as one that worked unless the outcome is inspected"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrubOutcome {
/// The scrub ran to completion and freed this many bytes — possibly zero,
/// which is a real answer.
Reclaimed(u64),
/// The container is not running, so there is no `docker exec` to run in.
/// Not a failure: a stopped container's writable layer is not growing, and
/// on the migration path it was already scrubbed while it was.
NotRunning,
/// The container was running, but the scrub did not finish within
/// [`SCRUB_TIMEOUT`].
TimedOut,
/// The container was running and the scrub genuinely failed.
Failed,
/// The exec ran, but the container's image does not have the tools the
/// scrub needs where it can reach them, so it deleted nothing.
///
/// Separate from [`ScrubOutcome::Failed`] because the exec itself worked
/// and the script ran to its own conclusion — and separate from
/// `Reclaimed(0)`, which is the whole of H3: a custom base image without
/// usr-merged coreutils reclaimed nothing and reported it as a completed
/// scrub of zero bytes.
Unavailable,
}
impl ScrubOutcome {
/// What the commit's log line should say about the scrub — **empty when
/// there is nothing to say**.
///
/// Two different zeros used to render identically here. Every migration
/// logged "0.00 MB dropped by the pre-commit scrub", because the migration
/// path scrubs the container itself while it is still running and stops it
/// before committing, so the call here finds nothing to exec into. A figure
/// of zero reads as a scrub that ran and found nothing, which is a
/// different and more alarming thing than a scrub that correctly had no
/// work left.
///
/// The other zero was H3: a scrub that could not find its tools also
/// reclaimed nothing, and rendered as the same reassuring "0.00 MB
/// dropped". That one is [`ScrubOutcome::Unavailable`] now, and even a
/// genuine `Reclaimed(0)` says it *ran* rather than quoting a figure that
/// cannot be told apart from a broken scrub's.
pub(crate) fn commit_log_suffix(&self) -> String {
match self {
Self::Reclaimed(0) => {
" (the pre-commit scrub ran and found nothing to drop)".to_string()
}
Self::Reclaimed(bytes) => format!(
" ({:.2} MB dropped by the pre-commit scrub)",
*bytes as f64 / 1_048_576.0
),
Self::NotRunning => String::new(),
Self::TimedOut => " (the pre-commit scrub timed out, so the layer keeps whatever it had)".to_string(),
Self::Failed => " (the pre-commit scrub did not run, so the layer keeps whatever it had)".to_string(),
Self::Unavailable => " (the pre-commit scrub could not run in this image, so nothing was reclaimed and the layer keeps whatever it had)".to_string(),
}
}
}
/// Delete the throwaway files listed in [`SNAPSHOT_SCRUB_PATHS`] from a
/// container's writable layer so the commit that follows does not bake them in.
///
/// Runs as **root**: the apt debris is root-owned while the scratchpads belong
/// to `claude`, and root can remove both. That is also what makes
/// [`snapshot_scrub_script`]'s containment checks load-bearing rather than
/// decorative.
///
/// **Never fails the caller, by design.** A scrub is an optimisation; a commit
/// is the only copy of the user's system layer. Losing some disk is a strictly
/// better outcome than refusing to snapshot, so every failure here is a log
/// line and nothing more — including the timeout, which stops waiting and lets
/// the commit proceed rather than leaving the UI on "Saving container state…"
/// with no way out.
///
/// ## Why it checks whether the container is running (M11)
///
/// This is a `docker exec`, so it only works on a running container.
/// `migrate_project_to_base` knows that: it scrubs the container itself and
/// *then* stops it, because the pre-swap commit is the largest snapshot
/// Triple-C ever takes. The call inside [`commit_container_snapshot`] therefore
/// arrives at a stopped container and could only ever fail, which logged
/// "could not run … committing anyway" on every single migration — training
/// the reader to ignore the one line that would matter if the scrub had really
/// broken. Asking first turns that into a debug line and keeps the warning
/// meaningful.
pub async fn scrub_writable_layer(container_id: &str) -> ScrubOutcome {
match is_container_running(container_id).await {
Ok(true) => {}
Ok(false) => {
log::debug!(
"Skipping pre-commit scrub of container {}: it is not running, so there is nothing to exec into and its writable layer is not growing",
container_id
);
return ScrubOutcome::NotRunning;
}
Err(e) => {
// Only Docker being unreachable reaches here, in which case the
// commit this precedes is about to fail on its own — but say so
// rather than reporting a skip that never happened.
log::warn!(
"Could not tell whether container {} is running ({}); skipping the pre-commit scrub and committing anyway",
container_id,
e
);
return ScrubOutcome::Failed;
}
}
// M2: not `/bin/sh -c script`. The shell an exec starts has already read
// the container's environment by the time the script's first line runs —
// see [`SCRUB_BOOTSTRAP`], which hands the script to one that has not.
let cmd = scrub_exec_cmd(&snapshot_scrub_script());
let exec = crate::docker::exec::exec_oneshot_as(
container_id,
"root",
cmd,
SCRUB_EXEC_ENV.iter().map(|e| (*e).to_string()).collect(),
);
// M12: nothing under `docker/` has a timeout, and this is the one call on
// the critical path of every recreate. Dropping the future stops us
// *waiting*; the `sh` keeps running inside the container and its deletions
// are still valid, they just stop counting towards the total. That is the
// right trade — the commit that follows is merely a little larger.
let exec_result = match tokio::time::timeout(SCRUB_TIMEOUT, exec).await {
Ok(result) => result,
Err(_) => {
log::warn!(
"Pre-commit scrub of container {} did not finish within {}s; committing anyway",
container_id,
SCRUB_TIMEOUT.as_secs()
);
return ScrubOutcome::TimedOut;
}
};
match exec_result {
Ok((output, _exit_code)) => {
// H3, first of the three silences: ask about "could not run"
// *before* reading a total, because the script prints no total in
// that case and a bare `None` would be reported as a generic
// failure without naming what the image is missing.
if let Some(reason) = parse_scrub_unavailable(&output) {
log::warn!(
"Pre-commit scrub of container {} could not run ({}); nothing was reclaimed and the commit will carry whatever the writable layer held",
container_id,
reason
);
return ScrubOutcome::Unavailable;
}
match parse_scrub_total(&output) {
// H3, second of the three: this used to log only when there
// was something to report, so a scrub that reclaimed nothing
// left no trace at all. A genuine zero is routine, so it is a
// debug line rather than a warning — the alarming zero is the
// `Unavailable` above, and that one is not quiet.
Some(0) => {
log::debug!(
"Pre-commit scrub of container {} ran and found nothing to reclaim",
container_id
);
ScrubOutcome::Reclaimed(0)
}
Some(bytes) => {
log::info!(
"Pre-commit scrub of container {} reclaimed {:.2} MB before it could be committed",
container_id,
bytes as f64 / 1_048_576.0
);
ScrubOutcome::Reclaimed(bytes)
}
None => {
log::warn!(
"Pre-commit scrub of container {} did not report a total; committing anyway. Output: {}",
container_id,
output.trim()
);
ScrubOutcome::Failed
}
}
}
Err(e) => {
log::warn!(
"Pre-commit scrub of container {} could not run ({}); committing anyway",
container_id,
e
);
ScrubOutcome::Failed
}
}
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal.
///
/// ## Why this passes a Config instead of `Default::default()`
///
/// `docker commit` bakes the *running container's* full ENV into the resulting
/// image. An earlier version of this function passed an empty `Config` and a
/// comment asserting that the API "gives no way to remove env vars", with
/// `MANAGED_AUTH_KEYS` cited as the defence. That was wrong on both counts.
///
/// `MANAGED_AUTH_KEYS` defends the *next container* — it overrides the image's
/// stale value at create time — and does nothing whatsoever about the value
/// sitting in the image. So `docker image inspect triple-c-snapshot-<id>:latest
/// --format '{{json .Config.Env}}'` returned the shared ~1-year OAuth token,
/// and kept returning it after the user revoked the token, because
/// `clear_claude_token` only deletes a keychain entry.
///
/// And the API does allow it. Verified against Engine 29.6: the config in the
/// commit body is **merged over** the container's config, key by key for `Env`,
/// with unmentioned fields (`Cmd`, `WorkingDir`, `Labels`, …) inherited
/// untouched. A key cannot be *dropped*, but it can be set — so every name in
/// [`SECRET_ENV_KEYS`] is committed as `KEY=`, which is exactly the "empty
/// means unset" convention the rest of the auth plumbing already uses.
///
/// Non-secret env (PATH, TZ, model aliases, instructions) is inherited as
/// before, so nothing about the snapshot's behaviour changes.
///
/// ## Why it scrubs first
///
/// See [`SNAPSHOT_SCRUB_PATHS`]. Every commit stacks a layer, so a file present
/// here is a file the project's image carries for the rest of its life.
///
/// The scrub is a no-op on a container that is already stopped — the migration
/// path scrubs it itself while it is still running and stops it before getting
/// here — and it can never fail this function; see [`scrub_writable_layer`].
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
// Drop the throwaway files *before* the commit captures them. A commit
// stacks a layer and never rewrites one, so anything present at this
// instant is paid for permanently — see [`SNAPSHOT_SCRUB_PATHS`]. Failure
// is swallowed inside; a scrub must never be able to block a snapshot.
let scrub = scrub_writable_layer(container_id).await;
// Parse repo:tag
let (repo, tag) = match image_name.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (image_name.clone(), "latest".to_string()),
};
let options = CommitContainerOptions {
container: container_id.to_string(),
repo: repo.clone(),
tag: tag.clone(),
pause: true,
..Default::default()
};
let config = Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
};
docker
.commit_container(options, config)
.await
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
log::info!(
"Committed container {} as snapshot {}:{}{}",
container_id,
repo,
tag,
scrub.commit_log_suffix()
);
Ok(())
}
/// `KEY=` for every name in [`SECRET_ENV_KEYS`] — the env override handed to
/// `docker commit` so no credential value reaches an image.
fn blanked_secret_env() -> Vec<String> {
SECRET_ENV_KEYS
.iter()
.map(|key| format!("{}=", key))
.collect()
}
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
/// name in [`SECRET_ENV_KEYS`].
fn env_holds_a_secret(env: &[String]) -> bool {
env.iter().any(|entry| {
let Some((key, value)) = entry.split_once('=') else {
return false;
};
!value.is_empty() && SECRET_ENV_KEYS.contains(&key)
})
}
/// Outcome of [`sweep_orphaned_snapshots`].
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SnapshotSweepReport {
/// Image ids that were removed.
pub removed: Vec<String>,
/// Bytes the removed images accounted for, as Docker reported them. A
/// shared-layer estimate, not a disk-usage measurement.
pub reclaimed_bytes: i64,
/// Orphans Docker refused to delete because a container is still built
/// from them. Normal, not a failure — the next sweep gets them.
pub in_use: usize,
/// Orphans that could not be removed for any other reason, with the error.
pub failed: Vec<(String, String)>,
/// Set when the engine could not be reached or listed at all.
pub unavailable: Option<String>,
}
/// The filter every sweep runs under. Extracted so a test can hold the two
/// conditions in place: **dangling** and **labelled as ours**. Losing either
/// one turns a snapshot sweep into a prune of the user's whole image store.
fn orphan_sweep_filters() -> HashMap<String, Vec<String>> {
HashMap::from([
("dangling".to_string(), vec!["true".to_string()]),
(
"label".to_string(),
vec![format!("{}=true", LABEL_MANAGED)],
),
])
}
/// Remove the untagged snapshot commits left behind by recreation.
///
/// Every recreation commits the container to `triple-c-snapshot-{id}:latest`
/// and moves that tag; the image the tag pointed at before keeps its layers and
/// loses its name. Nothing else deletes those, so a project that has been
/// recreated a dozen times leaves a dozen multi-gigabyte orphans behind.
///
/// Two conditions, and the safety of this whole function rests on them:
///
/// * **Dangling** — untagged. Every image the app relies on carries a tag:
/// `triple-c-snapshot-{id}:latest` is what a project is rebuilt from, and a
/// migration's `pre-migration-*` pin is the only copy of a rollback target.
/// Neither can ever match this filter, so neither can be swept.
/// * **`triple-c.managed=true`** — only images Triple-C itself built or
/// committed. `docker commit` copies the container's labels onto the image,
/// and `container/Dockerfile` stamps the same label on the base, which is
/// what makes it a reliable mark of provenance. The user's own dangling
/// images are none of our business — the daemon this runs against is shared
/// with their unrelated work, so an unfiltered prune is never an option.
///
/// **Superseded base images are collected by exactly the same two conditions.**
/// They used to be unreachable: `container/Dockerfile` carried no `LABEL` at
/// all, so a base image left untagged when a newer build claimed
/// `triple-c-sandbox:latest` was dangling but not labelled and could never
/// match. ~11.9 GB was measured stranded that way. The Dockerfile now stamps
/// `triple-c.managed=true`, so no change is needed here beyond knowing that
/// this function is what reclaims them.
///
/// Removal is not forced, so Docker refuses (409) while any container is still
/// built from the image — including the stopped containers of projects that are
/// not running. That refusal is the third safety net and it is the daemon's,
/// not ours; those orphans are simply counted and left for a later sweep.
/// **This is why `force: false` stays.** Forcing would untag and delete an
/// image out from under a stopped project, and Docker would leave that
/// container unable to start. A superseded base image pinned by one stopped
/// container is therefore not reclaimed until that project is next recreated or
/// removed, which the startup sweep will notice on some later run.
///
/// Never fails the caller: this is housekeeping, and a full disk is a better
/// outcome than a project that will not start.
pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport {
use bollard::image::ListImagesOptions;
let mut report = SnapshotSweepReport::default();
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
report.unavailable = Some(e);
return report;
}
};
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters: orphan_sweep_filters(),
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
report.unavailable = Some(format!("Could not list orphaned snapshots: {}", e));
return report;
}
};
for summary in images {
match docker
.remove_image(
&summary.id,
Some(RemoveImageOptions {
force: false,
noprune: false,
}),
None,
)
.await
{
Ok(_) => {
report.reclaimed_bytes += summary.size;
report.removed.push(summary.id);
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 409, ..
}) => {
report.in_use += 1;
}
Err(e) => {
report.failed.push((summary.id, e.to_string()));
}
}
}
if !report.removed.is_empty() || report.in_use > 0 {
log::info!(
"Snapshot sweep: removed {} orphan(s) ({:.2} GB), {} still in use by a container",
report.removed.len(),
report.reclaimed_bytes as f64 / 1_073_741_824.0,
report.in_use
);
}
report
}
/// Run [`sweep_orphaned_snapshots`] and write the whole outcome to the log,
/// tagged with *why* the sweep ran.
///
/// Every caller of the sweep is housekeeping fired off in a detached task, and
/// every one of them dropped the `SnapshotSweepReport` on the floor — including
/// `reclaimed_bytes`, `failed` and `unavailable`, which are the only evidence
/// that a sweep ever happened or that it could not. When a user asks where
/// 116 GB went, "nothing was logged" is not an answer. There is no UI for this
/// yet by deliberate choice (prevention first), so the log is the whole
/// interface.
pub async fn sweep_orphaned_snapshots_logged(context: &str) {
let report = sweep_orphaned_snapshots().await;
if let Some(ref why) = report.unavailable {
log::warn!("Snapshot sweep ({}) could not run: {}", context, why);
return;
}
log::info!(
"Snapshot sweep ({}): {} removed, {:.2} GB reclaimed, {} still pinned by a container, {} failed",
context,
report.removed.len(),
report.reclaimed_bytes as f64 / 1_073_741_824.0,
report.in_use,
report.failed.len(),
);
for (id, error) in &report.failed {
log::warn!("Snapshot sweep ({}) could not remove {}: {}", context, id, error);
}
}
/// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user
/// what actually happened rather than guessing.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SnapshotScrubReport {
/// Snapshot images that were found to hold a credential and were rewritten.
pub scrubbed: Vec<String>,
/// Snapshot images that hold a credential and could **not** be rewritten,
/// each with the reason. A non-empty list means the tag every future
/// container is built from still carries the credential.
pub failed: Vec<(String, String)>,
/// Tags that *were* rewritten, but whose superseded image object could not
/// be deleted — almost always because a container is still running off it.
///
/// Much weaker than `failed`, and normal rather than exceptional. The tag
/// is clean, so nothing new is built with the credential; what remains is
/// an untagged image whose config is still readable by id, for exactly as
/// long as the container using it survives — and that container already
/// holds the same value in its own env, so it is not a new exposure. The
/// rotation-id label mismatch recreates it on the next start, after which
/// an image prune collects the leftover.
pub superseded_retained: Vec<String>,
/// Set when the image list itself could not be read (Docker not running,
/// no permission). Nothing was scrubbed and nothing is known.
pub unavailable: Option<String>,
}
impl SnapshotScrubReport {
/// True when a credential is known to still be reachable through something
/// that will keep being used — a tag, or an unknown state.
pub fn left_something_behind(&self) -> bool {
!self.failed.is_empty() || self.unavailable.is_some()
}
}
/// Rewrite every `triple-c-snapshot-*` image whose ENV still carries a
/// credential, blanking the values in place.
///
/// Revoking a shared token has to mean something. Deleting the keychain entry
/// stops *new* containers getting it, but images committed before
/// [`commit_container_snapshot`] learned to strip secrets still have the token
/// in their config, and those images are the ones every future container of
/// that project is built from. This is the cleanup for them.
///
/// Mechanics: create (do not start) a throwaway container from the image, then
/// commit it straight back over the same tag with the secret keys blanked. The
/// new image shares every layer with the old one, so this costs no meaningful
/// disk and preserves the project's installed packages exactly. The superseded
/// image is then removed by id; if Docker refuses (some storage drivers will
/// not delete an image that is a parent of another), the report says so rather
/// than pretending the secret is gone.
///
/// Never fails the caller: an unreachable Docker engine is reported in the
/// return value, because the keychain deletion that precedes it must still
/// stand.
pub async fn scrub_secrets_from_snapshots() -> SnapshotScrubReport {
use bollard::image::ListImagesOptions;
let mut report = SnapshotScrubReport::default();
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
report.unavailable = Some(e);
return report;
}
};
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec!["triple-c-snapshot-*".to_string()],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
report.unavailable = Some(format!("Could not list snapshot images: {}", e));
return report;
}
};
for summary in images {
// `list_images` does not return Config, so inspect each candidate.
let details = match docker.inspect_image(&summary.id).await {
Ok(d) => d,
Err(e) => {
report
.failed
.push((summary.id.clone(), format!("could not inspect: {}", e)));
continue;
}
};
let env = details
.config
.as_ref()
.and_then(|c| c.env.clone())
.unwrap_or_default();
if !env_holds_a_secret(&env) {
continue;
}
if summary.repo_tags.is_empty() {
// The reference filter should make this impossible; if it happens,
// say so rather than silently leaving a credential in place.
report.failed.push((
summary.id.clone(),
"an untagged snapshot image holds a credential and cannot be rewritten"
.to_string(),
));
continue;
}
// Claim the project before touching its snapshot.
//
// This is the second writer of `triple-c-snapshot-{id}:latest`, after a
// recreate's commit, and it has the same
// read-modify-write shape: create a scratch container *from* the
// snapshot, then commit back over the same tag. A `:latest` move
// landing in between is silently overwritten by an image derived from
// the pre-read state — which here would mean re-baking the very
// credential this function exists to remove.
//
// A snapshot whose project is busy is left for the next call rather
// than rewritten unsafely, and it is *reported*: silently skipping a
// credential removal is the one outcome worse than failing it.
let project_id = summary
.repo_tags
.iter()
.find_map(|t| crate::docker::migration::parse_snapshot_reference(t))
.map(|(id, _tag)| id);
let _claim = match project_id.as_deref() {
Some(id) => match crate::project_lock::try_acquire(id, crate::project_lock::ProjectOp::SecretScrub) {
Ok(guard) => Some(guard),
Err(reason) => {
report.failed.push((summary.repo_tags.join(", "), reason));
continue;
}
},
// Not a `triple-c-snapshot-{uuid}` reference despite the filter.
// Nothing owns it, so there is nothing to serialise against.
None => None,
};
// Rewrite every tag this image answers to, so an old tag cannot keep
// serving the un-scrubbed config.
let mut all_tags_rewritten = true;
for tag in summary.repo_tags.iter() {
let (repo, tag_part) = match tag.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (tag.clone(), "latest".to_string()),
};
if let Err(e) = rewrite_image_without_secrets(&docker, tag, &repo, &tag_part).await {
all_tags_rewritten = false;
report.failed.push((tag.clone(), e));
} else {
report.scrubbed.push(tag.clone());
}
}
// Drop the superseded image so its config stops being inspectable.
// Best effort by design — see the doc comment.
if all_tags_rewritten {
if let Err(e) = docker
.remove_image(
&summary.id,
Some(RemoveImageOptions {
force: false,
noprune: false,
}),
None,
)
.await
{
// Expected whenever the project's container is still around:
// Docker will not delete an image a container was created
// from. Not a failure of the scrub — see `superseded_retained`.
log::info!(
"Scrubbed snapshot {} but kept the superseded image {}: {}",
summary.repo_tags.join(", "),
summary.id,
e
);
report
.superseded_retained
.push(summary.repo_tags.join(", "));
}
}
}
report
}
/// Commit `source_image` back over `repo:tag` with [`SECRET_ENV_KEYS`] blanked.
async fn rewrite_image_without_secrets(
docker: &bollard::Docker,
source_image: &str,
repo: &str,
tag: &str,
) -> Result<(), String> {
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple());
// `triple-c.scrub=true` so the Disk panel's scrub-container bucket can tell
// a *live* rewrite from a leftover by label rather than by age. An age gate
// alone was the previous discriminator, and a clock is a poor proxy for
// "somebody is using this": killing this container between the create and
// the commit below leaves the revoked credential baked into the snapshot's
// `Config.Env`, which is precisely the state this function exists to end.
let scratch_labels: HashMap<String, String> =
HashMap::from([(LABEL_SCRUB.to_string(), "true".to_string())]);
let created = docker
.create_container(
Some(CreateContainerOptions {
name: scratch_name.clone(),
..Default::default()
}),
Config::<String> {
image: Some(source_image.to_string()),
labels: Some(scratch_labels),
// Deliberately nothing else. The container is never started;
// its only job is to be a config to commit from, and every
// field left unset here is inherited from the image and
// inherited back out by the commit. Setting `cmd` to a
// placeholder — the obvious way to satisfy an image with no
// CMD — would write that placeholder into the rewritten
// snapshot. The base image has an ENTRYPOINT, so `create`
// needs no command; an image with neither fails here and is
// reported rather than silently mangled.
..Default::default()
},
)
.await
.map_err(|e| format!("could not create a scratch container: {}", e))?;
let commit = docker
.commit_container(
CommitContainerOptions {
container: created.id.clone(),
repo: repo.to_string(),
tag: tag.to_string(),
// Nothing is running; pausing a created container is an error.
pause: false,
..Default::default()
},
Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
},
)
.await
.map_err(|e| format!("could not re-commit without the credential: {}", e));
// Remove the scratch container whatever happened to the commit.
if let Err(e) = docker
.remove_container(
&created.id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
log::warn!("Could not remove scratch container {}: {}", scratch_name, e);
}
commit.map(|_| ())
}
/// Remove the snapshot image for a project (used on Reset / project removal).
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
docker
.remove_image(
&image_name,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
log::info!("Removed snapshot image {}", image_name);
Ok(())
}
/// Remove both named volumes for a project (used on Reset / project removal).
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
for vol in [
home_volume_name(&project.id),
config_volume_name(&project.id),
] {
match docker.remove_volume(&vol, None).await {
Ok(_) => log::info!("Removed volume {}", vol),
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
}
}
Ok(())
}
/// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ).
pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
expected_create_image: &str,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
.inspect_container(container_id, None)
.await
.map_err(|e| format!("Failed to inspect container: {}", e))?;
let labels = info
.config
.as_ref()
.and_then(|c| c.labels.as_ref());
let get_label = |name: &str| -> Option<String> {
labels.and_then(|l| l.get(name).cloned())
};
let mounts = info
.host_config
.as_ref()
.and_then(|hc| hc.mounts.as_ref());
// ── Docker socket mount ──────────────────────────────────────────────
// Intentionally NOT checked here. Toggling "Allow container spawning"
// should not trigger a full container recreation (which loses Claude
// Code settings stored in the named volume). The change takes effect
// on the next explicit rebuild instead.
// ── Backend ──────────────────────────────────────────────────────────
let current_backend = format!("{:?}", project.backend);
// Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
if let Some(container_backend) = container_backend {
if container_backend != current_backend {
log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend);
return Ok(true);
}
}
// ── Project paths fingerprint ──────────────────────────────────────────
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
match get_label("triple-c.paths-fingerprint") {
Some(container_fp) => {
if container_fp != expected_paths_fp {
log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp);
return Ok(true);
}
}
None => {
// Old container without paths-fingerprint label -> force recreation for migration
log::info!("Container missing paths-fingerprint label, triggering recreation for migration");
return Ok(true);
}
}
// ── Port mappings fingerprint ──────────────────────────────────────────
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
if container_ports_fp != expected_ports_fp {
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
return Ok(true);
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project, global_aws);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
if container_bedrock_fp != expected_bedrock_fp {
log::info!("Bedrock config mismatch");
return Ok(true);
}
// ── Ollama config fingerprint ────────────────────────────────────────
let expected_ollama_fp = compute_ollama_fingerprint(project, global_ollama);
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
if container_ollama_fp != expected_ollama_fp {
log::info!("Ollama config mismatch");
return Ok(true);
}
// ── llama.cpp config fingerprint ─────────────────────────────────────
// A missing label means the container predates the llama.cpp backend, in
// which case the expected fingerprint is also "" (no llamacpp_config) and
// nothing is recreated needlessly.
let expected_llamacpp_fp = compute_llamacpp_fingerprint(project, global_llamacpp);
let container_llamacpp_fp = get_label("triple-c.llamacpp-fingerprint").unwrap_or_default();
if container_llamacpp_fp != expected_llamacpp_fp {
log::info!("llama.cpp config mismatch");
return Ok(true);
}
// ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
if container_oai_fp != expected_oai_fp {
log::info!("OpenAI Compatible config mismatch");
return Ok(true);
}
// ── Create image ─────────────────────────────────────────────────────
// What this container was created from, against what we would create it
// from *now* — the caller resolves that (snapshot-if-it-exists, else the
// configured base) and passes it in as `expected_create_image`, preserving
// exactly today's semantics.
//
// This replaces a check that compared the container's actual image against
// the `triple-c.image` label. `create_container` wrote that label from the
// very image it created from, so the two could never differ: it was a
// tautology that never once fired, and it is the reason a project stayed
// pinned to its own snapshot lineage forever.
//
// A missing `triple-c.create-image` label means the container predates this
// fix — unknown, so leave it alone rather than churn every existing
// container on first launch after an update.
if let Some(container_create_image) = get_label(crate::docker::migration::LABEL_CREATE_IMAGE) {
if container_create_image != expected_create_image {
log::info!(
"Create-image mismatch (container={:?}, expected={:?})",
container_create_image,
expected_create_image
);
return Ok(true);
}
}
// ── Base image id: deliberately NOT compared here ────────────────────
// This departs from the CLAUDE.md rule that new container state gets a
// label and a comparison, and the departure is the point.
//
// `triple-c.base-image-id` records which base a container's lineage
// descends from. Comparing it here would mean that publishing a new base
// image silently recreates every project on next start — and, because
// `expected_create_image` is the snapshot whenever one exists, it would
// recreate them *from their own snapshot*: pure churn, on the old base,
// with no benefit. Worse, it would consume the very signal ("this project
// is behind the base") that is supposed to prompt the user, without
// actually migrating anything.
//
// Staleness is therefore a *surfaced* signal gating an explicit user
// action — `get_container_staleness` / `migrate_project_to_base` — not an
// automatic recreation trigger.
// ── Timezone ─────────────────────────────────────────────────────────
let expected_tz = timezone.unwrap_or("");
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
if container_tz != expected_tz {
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
return Ok(true);
}
// ── SSH key path mount ───────────────────────────────────────────────
let ssh_mount_source = mounts
.and_then(|m| {
m.iter()
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
})
.and_then(|mount| mount.source.as_deref());
let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if ssh_mount_source != effective_ssh {
log::info!(
"SSH key path mismatch (container={:?}, expected={:?})",
ssh_mount_source,
effective_ssh
);
return Ok(true);
}
// ── Corporate CA certificates ────────────────────────────────────────
// Both the resolved path and the certificate contents, so replacing a
// rotated CA at the same path recreates the container — the copy inside
// the container is made once, at start, and nothing else would notice.
//
// A container predating this feature has no label, i.e. "", which is also
// what an unconfigured CA fingerprints as — so existing installs are not
// churned until a CA is actually set.
let expected_ca_fp = ca_certs::compute_ca_fingerprint(resolve_with_global(
project.ca_cert_path.as_deref(),
default_ca_cert_path,
));
let container_ca_fp = get_label("triple-c.ca-fingerprint").unwrap_or_default();
if container_ca_fp != expected_ca_fp {
log::info!(
"Corporate CA certificate mismatch (container={:?}, expected={:?})",
container_ca_fp,
expected_ca_fp
);
return Ok(true);
}
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.as_deref()
.or(default_git_user_name)
.unwrap_or_default()
.to_string();
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
if container_git_name != expected_git_name {
log::info!("GIT_USER_NAME mismatch (container={:?}, expected={:?})", container_git_name, expected_git_name);
return Ok(true);
}
let expected_git_email = project.git_user_email.as_deref()
.or(default_git_user_email)
.unwrap_or_default()
.to_string();
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
if container_git_email != expected_git_email {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, expected={:?})", container_git_email, expected_git_email);
return Ok(true);
}
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
if container_git_token_hash != expected_git_token_hash {
log::info!("GIT_TOKEN mismatch");
return Ok(true);
}
// ── Shared Claude Code OAuth token ───────────────────────────────────
// Compares rotation ids, so this fires when the token is first acquired,
// re-acquired (rotated), revoked, or opted out of. Both "" means no token
// is in play, which is also what a container predating this feature reports
// — so existing installs are not recreated until a token actually exists.
// Recreation is the only way to change a container's env, so it is the only
// way a revoked token stops being live in one. It does *not* clean the
// snapshot image — `clear_claude_token` calls
// `scrub_secrets_from_snapshots` for that.
let expected_claude_token = claude_token_label(project);
let container_claude_token = get_label("triple-c.claude-token-version").unwrap_or_default();
if container_claude_token != expected_claude_token {
log::info!("Shared Claude authentication token mismatch — recreating container");
return Ok(true);
}
// ── Custom environment variables (label-based fingerprint) ──────────
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env);
let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
return Ok(true);
}
// ── Mission Control ────────────────────────────────────────────────────
let expected_mc = project.mission_control_enabled.to_string();
let container_mc = get_label("triple-c.mission-control").unwrap_or_else(|| "false".to_string());
if container_mc != expected_mc {
log::info!("Mission Control mismatch (container={:?}, expected={:?})", container_mc, expected_mc);
return Ok(true);
}
// ── VPN support (NET_ADMIN + /dev/net/tun + sysctl) ───────────────────
// A container's capabilities, devices and sysctls are set at creation and
// cannot be changed on a running or stopped container, so recreation is the
// only way a toggle here takes effect. A missing label means the container
// predates the feature, which is the same thing as having it off — so
// existing projects are not churned until someone actually turns it on.
let expected_vpn = project.vpn_support_enabled.to_string();
let container_vpn = get_label("triple-c.vpn-support").unwrap_or_else(|| "false".to_string());
if container_vpn != expected_vpn {
log::info!("VPN support mismatch (container={:?}, expected={:?})", container_vpn, expected_vpn);
return Ok(true);
}
// ── Permission mode ────────────────────────────────────────────────────
// The mode is injected as the TRIPLE_C_PERMISSION_MODE env var, and
// container env can only change by recreating the container. A missing
// label means the container predates this feature and therefore has no
// such env var, so it must be recreated too (empty != any valid mode).
let expected_permission_mode = project.effective_permission_mode().as_env_value();
let container_permission_mode = get_label("triple-c.permission-mode").unwrap_or_default();
if container_permission_mode != expected_permission_mode {
log::info!(
"Permission mode mismatch (container={:?}, expected={:?})",
container_permission_mode,
expected_permission_mode
);
return Ok(true);
}
// ── Claude instructions (label-based fingerprint) ─────────────────────
let expected_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
if container_instructions_fp != expected_instructions_fp {
log::info!("CLAUDE_INSTRUCTIONS mismatch");
return Ok(true);
}
// ── Claude Code settings fingerprint ───────────────────────────────
let merged_cc = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref(), project.sandbox_mode_enabled);
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
if container_cc_fp != expected_cc_fp {
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
return Ok(true);
}
// ── Legacy MCP migration shim ───────────────────────────────────────
// One-release migration for containers created before the built-in MCP
// feature was removed. Such containers carry a `triple-c.mcp-fingerprint`
// label and/or are attached to the per-project `triple-c-net-<id>` network.
// That user-defined network is deleted during cleanup, and a container
// whose NetworkMode points at a missing network refuses to start — so force
// a recreation to move them onto the default bridge. Containers created by
// the current code never carry the label or the network, so this is a no-op
// for them and can be dropped a release later.
if let Some(fp) = get_label("triple-c.mcp-fingerprint") {
if !fp.is_empty() {
log::info!("Legacy container carries triple-c.mcp-fingerprint label — recreating without MCP");
return Ok(true);
}
}
let legacy_network = info
.host_config
.as_ref()
.and_then(|hc| hc.network_mode.as_deref())
.map(|nm| nm.starts_with("triple-c-net-"))
.unwrap_or(false);
if legacy_network {
log::info!("Legacy container attached to a triple-c-net-* network — recreating without MCP");
return Ok(true);
}
Ok(false)
}
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
if let Some(ref container_id) = project.container_id {
let docker = get_docker()?;
match docker.inspect_container(container_id, None).await {
Ok(info) => {
let status = info
.state
.and_then(|s| s.status)
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "unknown".to_string());
// Read actual image from Docker inspect
let image = info
.config
.and_then(|c| c.image)
.unwrap_or_else(|| "unknown".to_string());
Ok(Some(ContainerInfo {
container_id: container_id.clone(),
project_id: project.id.clone(),
status,
image,
}))
}
Err(_) => Ok(None),
}
} else {
Ok(None)
}
}
/// Check whether a Docker container is currently running.
/// Returns false if the container doesn't exist or Docker is unavailable.
pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
let docker = get_docker()?;
match docker.inspect_container(container_id, None).await {
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
Err(_) => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
const OPUS: &str = ANTHROPIC_DEFAULT_OPUS_MODEL;
const SONNET: &str = ANTHROPIC_DEFAULT_SONNET_MODEL;
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
compute_model_aliases(model, haiku)
}
#[test]
fn all_four_aliases_fall_back_to_the_configured_model() {
assert_eq!(
aliases(Some("qwen3.5:27b"), None),
vec![
(OPUS, "qwen3.5:27b".to_string()),
(SONNET, "qwen3.5:27b".to_string()),
(HAIKU, "qwen3.5:27b".to_string()),
(FABLE, "qwen3.5:27b".to_string()),
]
);
}
#[test]
fn the_haiku_override_replaces_only_the_haiku_alias() {
let got = aliases(Some("big-model"), Some("small-model"));
assert_eq!(
got,
vec![
(OPUS, "big-model".to_string()),
(SONNET, "big-model".to_string()),
(HAIKU, "small-model".to_string()),
(FABLE, "big-model".to_string()),
]
);
}
#[test]
fn a_blank_or_whitespace_haiku_override_falls_back_to_the_model() {
for override_value in [Some(""), Some(" "), None] {
let got = aliases(Some("m"), override_value);
assert_eq!(
got.iter().find(|(k, _)| *k == HAIKU).map(|(_, v)| v.as_str()),
Some("m"),
"override {:?} should fall back to the model id",
override_value
);
}
}
#[test]
fn values_are_trimmed() {
assert_eq!(
aliases(Some(" m "), Some(" h ")),
vec![
(OPUS, "m".to_string()),
(SONNET, "m".to_string()),
(HAIKU, "h".to_string()),
(FABLE, "m".to_string()),
]
);
}
#[test]
fn no_model_and_no_override_emits_nothing() {
// Nothing to point the aliases at — leave Claude Code's defaults alone
// rather than injecting empty vars.
assert!(aliases(None, None).is_empty());
assert!(aliases(Some(""), Some(" ")).is_empty());
}
#[test]
fn a_haiku_override_alone_still_fixes_background_calls() {
// No model id configured, but the user pointed haiku somewhere: emit
// just that one, because it is the alias background work uses.
assert_eq!(
aliases(None, Some("small-model")),
vec![(HAIKU, "small-model".to_string())]
);
}
#[test]
fn only_custom_endpoint_backends_get_aliases() {
assert!(!Backend::Anthropic.uses_custom_endpoint());
assert!(!Backend::Bedrock.uses_custom_endpoint());
assert!(Backend::Ollama.uses_custom_endpoint());
assert!(Backend::LlamaCpp.uses_custom_endpoint());
assert!(Backend::OpenAiCompatible.uses_custom_endpoint());
}
#[test]
fn every_alias_var_is_reserved_and_managed() {
for key in [OPUS, SONNET, HAIKU, FABLE] {
assert!(is_reserved_env_key(key), "{} must be reserved", key);
assert!(
is_reserved_env_key(&key.to_lowercase()),
"{} must be reserved case-insensitively",
key
);
}
// A user-set alias must never survive into the container env.
let fp = compute_env_fingerprint(&[EnvVar {
key: HAIKU.to_string(),
value: "sneaky".to_string(),
}]);
assert_eq!(fp, "");
}
#[test]
fn vpn_support_off_touches_nothing_in_the_host_config() {
// The default must stay byte-identical to a container created before the
// feature existed, or every project recreates on the next start.
let (cap_add, devices, sysctls) = vpn_host_config(false);
assert_eq!(cap_add, None);
assert_eq!(devices, None);
assert_eq!(sysctls, None);
}
#[test]
fn vpn_support_on_grants_all_three_pieces() {
// Each is useless without the others — a client with the capability but
// no device, or the device but no capability, still times out — so this
// asserts the whole set rather than any one of them.
let (cap_add, devices, sysctls) = vpn_host_config(true);
assert_eq!(cap_add, Some(vec!["NET_ADMIN".to_string()]));
let devices = devices.expect("the tun device must be passed through");
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].path_on_host.as_deref(), Some(TUN_DEVICE));
assert_eq!(devices[0].path_in_container.as_deref(), Some(TUN_DEVICE));
assert_eq!(devices[0].cgroup_permissions.as_deref(), Some("rwm"));
assert_eq!(
sysctls
.expect("wireguard needs src_valid_mark")
.get("net.ipv4.conf.all.src_valid_mark")
.map(String::as_str),
Some("1")
);
}
#[test]
fn vpn_support_never_grants_more_than_net_admin() {
// NET_ADMIN is already a step out of the sandbox. Anything else added
// here (SYS_ADMIN, or a blanket privileged flag) would be a much larger
// one, so pin the set.
let (cap_add, _, _) = vpn_host_config(true);
assert_eq!(cap_add.unwrap(), vec!["NET_ADMIN"]);
}
#[test]
fn the_vpn_skill_flag_is_emitted_either_way_never_omitted() {
// The whole removal path depends on this. If `false` ever became "emit
// nothing", a project that had the toggle on would keep the skill
// forever: the container recreates from a snapshot whose baked
// VPN_SUPPORT_ENABLED=1 would then go unchallenged, and entrypoint
// would reinstall a skill for a capability the container no longer has.
assert_eq!(vpn_env_var(true), "VPN_SUPPORT_ENABLED=1");
assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0");
}
#[test]
fn the_vpn_skill_flag_is_reserved_from_custom_env() {
// entrypoint.sh installs and removes the pia-vpn skill from this
// variable. A custom env var of the same name would let a project claim
// the skill without the capability behind it — or keep it after the
// toggle is off — so it has to be unsettable like the others.
assert!(is_reserved_env_key("VPN_SUPPORT_ENABLED"));
assert!(is_reserved_env_key("vpn_support_enabled"));
assert_eq!(
compute_env_fingerprint(&[EnvVar {
key: "VPN_SUPPORT_ENABLED".to_string(),
value: "1".to_string(),
}]),
""
);
}
/// What bollard actually hands us when a tun-less host rejects the device.
///
/// Captured verbatim from Docker 29.7: `docker create` with a missing
/// device **succeeds**, and this arrives from the subsequent `start`.
/// `DockerResponseServerError`'s Display is
/// `"Docker responded with status code {code}: {message}"` with the
/// daemon's message unaltered.
const REAL_TUN_ERROR: &str = "Docker responded with status code 500: error \
gathering device information while adding custom device \
\"/dev/net/tun\": no such file or directory";
#[test]
fn a_missing_tun_device_is_explained_on_the_path_that_actually_fails() {
// The start path is the one that matters: the daemon defers device
// resolution to runc, so create returns an id on a host with no tun
// module and only start fails. A version of this that checked create
// alone would be dead code.
let msg = explain_container_failure("start", REAL_TUN_ERROR);
assert!(msg.starts_with("Failed to start container:"), "{}", msg);
assert!(msg.contains("VPN support"), "should name the switch: {}", msg);
assert!(msg.contains("tun` module"), "should name the cause: {}", msg);
assert!(msg.contains("Config → Runtime"), "should say where to fix it: {}", msg);
assert!(msg.contains(REAL_TUN_ERROR), "should keep the original: {}", msg);
}
#[test]
fn the_same_explanation_covers_create_if_the_daemon_ever_checks_earlier() {
// Belt and braces — older and future daemons may validate at create.
let msg = explain_container_failure("create", REAL_TUN_ERROR);
assert!(msg.starts_with("Failed to create container:"), "{}", msg);
assert!(msg.contains("VPN support"), "{}", msg);
}
#[test]
fn unrelated_failures_are_left_alone() {
for (action, err) in [
("create", "Conflict. The container name \"/triple-c-x\" is already in use"),
("start", "Docker responded with status code 404: No such container"),
("start", "error gathering device information while adding custom device \"/dev/dri/card0\""),
] {
assert_eq!(
explain_container_failure(action, err),
format!("Failed to {} container: {}", action, err),
"{} should pass through untouched",
err
);
}
}
#[test]
fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() {
// Both conditions are load-bearing. Without `dangling` the sweep would
// match `triple-c-snapshot-{id}:latest` — what every project is rebuilt
// from — and a migration's `pre-migration-*` pin, which is the only copy
// of a rollback target. Without the label it would match every dangling
// image on the user's machine.
let filters = orphan_sweep_filters();
assert_eq!(filters.get("dangling"), Some(&vec!["true".to_string()]));
assert_eq!(
filters.get("label"),
Some(&vec!["triple-c.managed=true".to_string()])
);
assert_eq!(filters.len(), 2, "an extra filter widens or narrows the sweep");
}
#[test]
fn the_custom_env_fingerprint_never_carries_the_value() {
// It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`
// hands to anything on the host, `docker commit` copies onto the
// project's snapshot image, and the recreation check logs on a mismatch.
// **Never a real credential.** This literal was the maintainer's actual
// Gitea token for fourteen days and ninety-two commits, on a public
// mirror — in a test whose whole subject is that secrets do not escape.
// A fixture only has to be *a value*; it never has to be a live one, so
// there is no version of this that justifies pasting something real.
let secret = "not-a-real-token-0000000000000000000000";
let fp = compute_env_fingerprint(&[EnvVar {
key: "TEA_TOKEN".to_string(),
value: secret.to_string(),
}]);
assert!(!fp.contains(secret), "fingerprint leaked the value: {}", fp);
assert!(!fp.contains("TEA_TOKEN"), "fingerprint leaked the key: {}", fp);
assert_eq!(fp.len(), 64, "expected a sha256 hex digest, got {:?}", fp);
// It still has to move when the value does, or a rotated token would
// never reach the container.
let rotated = compute_env_fingerprint(&[EnvVar {
key: "TEA_TOKEN".to_string(),
value: "rotated".to_string(),
}]);
assert_ne!(fp, rotated);
}
#[test]
fn the_deprecated_small_fast_model_var_is_never_emitted() {
let rendered: Vec<String> = aliases(Some("m"), Some("h"))
.into_iter()
.map(|(k, _)| k.to_string())
.collect();
assert!(!rendered.iter().any(|k| k == "ANTHROPIC_SMALL_FAST_MODEL"));
}
#[test]
fn the_alias_fingerprint_tracks_both_the_model_and_the_override() {
let base = model_alias_fingerprint_part(Some("m"), None);
assert_eq!(base, model_alias_fingerprint_part(Some("m"), Some("")));
assert_ne!(base, model_alias_fingerprint_part(Some("m2"), None));
assert_ne!(base, model_alias_fingerprint_part(Some("m"), Some("h")));
assert_eq!(model_alias_fingerprint_part(None, None), "");
}
fn project_with_llamacpp(model: Option<&str>, haiku: Option<&str>) -> Project {
let mut p = Project::new("t".to_string(), Vec::new());
p.backend = Backend::LlamaCpp;
p.llamacpp_config = Some(crate::models::LlamaCppConfig {
base_url: "http://host.docker.internal:8080".to_string(),
model_id: model.map(str::to_string),
haiku_model_id: haiku.map(str::to_string),
});
p
}
#[test]
fn llamacpp_fingerprint_changes_when_the_haiku_override_changes() {
let g = GlobalLlamaCppSettings::default();
let a = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), None), &g);
let b = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), Some("h")), &g);
assert_ne!(a, b, "the haiku override must force a container recreation");
// No config at all -> empty, so projects on other backends are not
// flagged for recreation by this fingerprint.
let plain = Project::new("t".to_string(), Vec::new());
assert_eq!(compute_llamacpp_fingerprint(&plain, &g), "");
}
#[test]
fn llamacpp_global_defaults_fill_in_for_blank_per_project_fields() {
let g = GlobalLlamaCppSettings {
base_url: Some("http://elsewhere:8080".to_string()),
default_model_id: Some("global-model".to_string()),
default_haiku_model_id: Some("global-haiku".to_string()),
};
// The per-project base URL is set in the fixture, so only the model and
// haiku fields fall through to the globals. Filling them in from the
// globals must be indistinguishable from setting them per-project.
let with_global = compute_llamacpp_fingerprint(&project_with_llamacpp(None, None), &g);
let explicit = compute_llamacpp_fingerprint(
&project_with_llamacpp(Some("global-model"), Some("global-haiku")),
&GlobalLlamaCppSettings::default(),
);
assert_eq!(with_global, explicit);
// …and changing a global must change the fingerprint, so a global-only
// edit still forces a recreation.
assert_ne!(
with_global,
compute_llamacpp_fingerprint(
&project_with_llamacpp(None, None),
&GlobalLlamaCppSettings {
default_haiku_model_id: Some("other-haiku".to_string()),
..g.clone()
},
)
);
assert_eq!(
resolve_with_global(None, g.default_haiku_model_id.as_deref()),
Some("global-haiku")
);
assert_eq!(
resolve_with_global(Some(" "), g.default_model_id.as_deref()),
Some("global-model")
);
}
#[test]
fn backend_serde_round_trips_llamacpp_and_accepts_legacy_spellings() {
assert_eq!(
serde_json::to_string(&Backend::LlamaCpp).unwrap(),
"\"llama_cpp\""
);
for spelling in ["\"llama_cpp\"", "\"llamacpp\"", "\"llama-cpp\"", "\"llama.cpp\""] {
let parsed: Backend = serde_json::from_str(spelling).unwrap();
assert_eq!(parsed, Backend::LlamaCpp, "failed for {}", spelling);
}
}
#[test]
fn a_project_json_without_llamacpp_config_still_deserialises() {
// `projects.json` written by an older build has no llamacpp_config key.
let json = serde_json::json!({
"id": "p1",
"name": "old",
"paths": [],
"container_id": null,
"status": "stopped",
"backend": "ollama",
"bedrock_config": null,
"ollama_config": { "base_url": "http://x:11434", "model_id": "m" },
"openai_compatible_config": null,
"allow_docker_access": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
});
let p: Project = serde_json::from_value(json).unwrap();
assert!(p.llamacpp_config.is_none());
// The new per-backend haiku override also defaults cleanly.
assert!(p.ollama_config.unwrap().haiku_model_id.is_none());
}
// ── Snapshot secret stripping ────────────────────────────────────────
// The bug these cover: `docker commit` copies the container's whole
// environment into `triple-c-snapshot-{id}:latest`, that image outlives
// every container built from it, and revoking the shared token used to
// touch only the keychain — so `docker image inspect` kept returning a
// live ~1-year OAuth credential indefinitely.
#[test]
fn the_commit_override_blanks_every_credential_bearing_key() {
let blanked = blanked_secret_env();
assert_eq!(blanked.len(), SECRET_ENV_KEYS.len());
for key in SECRET_ENV_KEYS {
assert!(
blanked.contains(&format!("{}=", key)),
"{} is not blanked at commit time",
key
);
}
// Blanked, never omitted: the commit endpoint merges this over the
// container's env key by key, so a name left out is inherited with its
// original value.
assert!(blanked.iter().all(|e| e.ends_with('=')));
}
#[test]
fn the_shared_claude_token_is_one_of_the_stripped_keys() {
assert!(SECRET_ENV_KEYS.contains(&CLAUDE_OAUTH_TOKEN_ENV));
}
#[test]
fn an_image_holding_a_credential_is_detected() {
let env = vec![
"PATH=/usr/bin".to_string(),
format!("{}=sk-ant-oat01-{}", CLAUDE_OAUTH_TOKEN_ENV, "x".repeat(90)),
];
assert!(env_holds_a_secret(&env));
}
#[test]
fn a_blanked_or_secret_free_image_is_left_alone() {
// Already scrubbed.
assert!(!env_holds_a_secret(&blanked_secret_env()));
// Never had one.
assert!(!env_holds_a_secret(&[
"PATH=/usr/bin".to_string(),
"TZ=UTC".to_string(),
format!("{}=claude-sonnet-4-5", ANTHROPIC_DEFAULT_SONNET_MODEL),
]));
// A non-secret var whose *name* merely contains a secret name.
assert!(!env_holds_a_secret(&[
format!("MY_{}=not-a-secret", CLAUDE_OAUTH_TOKEN_ENV)
]));
}
#[test]
fn a_value_containing_an_equals_sign_is_still_recognised() {
let env = vec!["ANTHROPIC_AUTH_TOKEN=abc=def==".to_string()];
assert!(env_holds_a_secret(&env));
}
#[test]
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
let clean = SnapshotScrubReport {
scrubbed: vec!["triple-c-snapshot-a:latest".to_string()],
..Default::default()
};
assert!(!clean.left_something_behind());
let partial = SnapshotScrubReport {
failed: vec![("triple-c-snapshot-b:latest".to_string(), "nope".to_string())],
..Default::default()
};
assert!(partial.left_something_behind());
let blind = SnapshotScrubReport {
unavailable: Some("Docker is not running".to_string()),
..Default::default()
};
assert!(blind.left_something_behind());
}
/// A stored row that cannot make a usable mount makes none.
///
/// Both shapes here are records already written to disk, which is the whole
/// point: an empty `host_path` sends
/// `field Source must not be empty` back for the *entire* create, so the
/// project cannot be started or recreated at all until the row is gone —
/// and validating the next save does nothing for a record that is already
/// there. An empty `mount_name` is the quieter one: it targets
/// `/workspace/` itself, so the daemon mounts that row over the workspace
/// volume and then creates the other rows' mount points inside the user's
/// real folder.
#[test]
fn a_stored_path_row_that_cannot_mount_produces_no_mount() {
use crate::models::project::ProjectPath;
let row = |host: &str, name: &str| ProjectPath {
host_path: host.to_string(),
mount_name: name.to_string(),
};
let mounts = project_path_mounts(&[
row("/home/me/code", "code"),
// Bricks the create outright.
row("", "orphan"),
// Mounts over /workspace itself.
row("/home/me/other", ""),
// Whitespace is not a name and not a path, however it got there.
row(" ", "blank-source"),
row("/home/me/third", " "),
row("/home/me/docs", "docs"),
]);
assert_eq!(
mounts.len(),
2,
"a row that cannot mount was turned into a mount anyway: {:?}",
mounts
);
assert_eq!(mounts[0].target.as_deref(), Some("/workspace/code"));
assert_eq!(mounts[0].source.as_deref(), Some("/home/me/code"));
assert_eq!(mounts[1].target.as_deref(), Some("/workspace/docs"));
// The two failures, spelled out so a future edit cannot reintroduce
// either by relaxing half the filter.
for m in &mounts {
assert_ne!(m.source.as_deref(), Some(""), "an empty Source reaches the daemon");
assert_ne!(
m.target.as_deref(),
Some("/workspace/"),
"a row is mounted over the workspace volume"
);
}
// And the good rows are unchanged: this is a filter, not a rewrite.
assert!(mounts.iter().all(|m| m.read_only == Some(false)));
assert!(project_path_mounts(&[]).is_empty());
}
// ── Pre-commit scrub (A1, C1) ────────────────────────────────────────────
#[test]
fn no_scrub_path_can_reach_a_host_bind_mount() {
// `/workspace/{mount_name}` is the user's own project directory, bound
// in from the host. Nothing in this list may ever name one — and the
// two read-only host mounts under /tmp must be equally unreachable.
//
// Necessary, not sufficient: a pattern that looks like this can still
// resolve into a bind mount through a symlinked parent, which is what
// the script's own checks exist for. See
// `the_scrub_script_refuses_a_symlinked_parent_...` below.
for path in SNAPSHOT_SCRUB_PATHS {
assert!(
path.starts_with('/'),
"{} is not absolute, so the shell would resolve it against an unknown cwd",
path
);
assert!(
!path.starts_with("/workspace"),
"{} reaches into a host bind mount",
path
);
assert!(
!path.starts_with("/tmp/."),
"{} could select /tmp/.host-ca or /tmp/.host-aws",
path
);
assert!(
!path.starts_with("/home"),
"{} reaches into the persisted home volume",
path
);
}
}
#[test]
fn no_scrub_path_is_a_whole_system_directory() {
// A trailing `/*` on a directory the system needs is fine; the
// directory *itself* is not. Guards against an edit that shortens an
// entry by one path component.
const FORBIDDEN: &[&str] = &[
"/", "/tmp", "/var", "/var/lib", "/var/log", "/var/cache", "/etc", "/usr", "/opt",
"/workspace", "/home", "/home/claude", "/var/lib/apt", "/var/cache/apt",
];
for path in SNAPSHOT_SCRUB_PATHS {
let trimmed = path.trim_end_matches('/');
assert!(
!FORBIDDEN.contains(&trimmed),
"{} would delete a directory the container needs",
path
);
}
}
#[test]
fn every_scrub_pattern_keeps_its_glob_in_the_final_component() {
// The script can only validate a parent directory it can name, so the
// parent has to be literal. An entry like `/var/*/apt/*` would put a
// glob in a path *component*, which is the shape that made C1
// exploitable in the first place.
for pattern in SNAPSHOT_SCRUB_PATHS {
let (dir, glob) = split_scrub_pattern(pattern)
.unwrap_or_else(|| panic!("{} has no literal parent directory", pattern));
assert!(
!dir.contains(['*', '?', '[']),
"{}: the parent {} is itself a glob, so the script cannot check it",
pattern,
dir
);
assert!(!glob.is_empty(), "{} has an empty final component", pattern);
// Both halves are single-quoted into the script; a quote or a blank
// would break out of that quoting and change what runs as root.
assert!(
!pattern.contains('\'') && !pattern.contains(char::is_whitespace),
"{} cannot be safely single-quoted into the scrub script",
pattern
);
}
}
#[test]
fn every_scrub_parent_is_inside_the_scripts_containment_allowlist() {
// The allowlist is hardcoded in the script and deliberately not derived
// from this list, so the two can drift apart — in which case the entry
// silently stops being scrubbed. Fail here instead.
for pattern in SNAPSHOT_SCRUB_PATHS {
let (dir, _) = split_scrub_pattern(pattern).expect("a literal parent");
assert!(
SCRUB_CONTAINMENT_PREFIXES
.iter()
.any(|p| dir == *p || dir.starts_with(&format!("{}/", p))),
"{} is anchored at {}, which the script's containment check would refuse",
pattern,
dir
);
}
}
#[test]
fn the_containment_allowlist_cannot_reach_anything_of_the_users() {
for prefix in SCRUB_CONTAINMENT_PREFIXES {
assert!(prefix.starts_with('/'), "{} is not absolute", prefix);
assert!(!prefix.ends_with('/'), "{} would match the wrong things", prefix);
for forbidden in [
"/", "/workspace", "/home", "/etc", "/usr", "/opt", "/srv", "/root",
"/var/lib/postgresql", "/var/lib/docker",
] {
assert!(
!(forbidden == *prefix || forbidden.starts_with(&format!("{}/", prefix))),
"the allowlist entry {} contains {}",
prefix,
forbidden
);
}
}
}
#[test]
fn the_scrub_list_covers_every_measured_source_of_writable_layer_growth() {
// Each of these was measured in a real container's pending commit.
// Dropping one silently gives back multiple gigabytes per project.
for expected in [
"/tmp/claude-*", // agent scratchpads, 3.0 GB measured
"/tmp/triple-c-drops/*", // terminal drag-and-drop staging
"/tmp/clipboard_*.png", // pasted images
"/var/lib/apt/lists/*", // runtime apt, no `apt-get clean` behind it
"/var/cache/apt/archives/*.deb",
"/var/log/apt/*",
"/var/log/dpkg.log",
] {
assert!(
SNAPSHOT_SCRUB_PATHS.contains(&expected),
"{} is no longer scrubbed before commit",
expected
);
}
}
#[test]
fn the_users_own_files_are_only_scrubbed_once_they_are_stale() {
// Regression: these two hold files the user handed the agent by hand,
// and deleting them at commit time turned "change a setting" into
// "silently lose the file you just dropped". They stay in the list —
// nothing else ever reclaims them — but only with an age gate.
for user_owned in ["/tmp/triple-c-drops/*", "/tmp/clipboard_*.png"] {
assert!(
SNAPSHOT_SCRUB_PATHS.contains(&user_owned),
"{} was removed from the list instead of age-limited, so nothing reclaims it",
user_owned
);
let age = SCRUB_MIN_AGE_DAYS.iter().find(|(p, _)| *p == user_owned);
assert!(
age.is_some_and(|(_, days)| *days >= 7),
"{} is scrubbed without a meaningful age limit, which loses a just-dropped file",
user_owned
);
}
// The scratchpads are machine debris and are not age-limited; if that
// ever changes, the comment explaining why must change with it.
assert!(!SCRUB_MIN_AGE_DAYS.iter().any(|(p, _)| *p == "/tmp/claude-*"));
// An age limit on a pattern that is not scrubbed at all does nothing
// and reads as though it does.
for (pattern, _) in SCRUB_MIN_AGE_DAYS {
assert!(SNAPSHOT_SCRUB_PATHS.contains(pattern), "{} is not scrubbed", pattern);
}
}
#[test]
fn the_scrub_script_validates_every_directory_before_deleting_in_it() {
// The structural half of the C1 fix, for the environments where the
// `/bin/sh` test below cannot run. Each assertion here pins one of the
// five checks; deleting any of them from the script fails this test.
let script = snapshot_scrub_script();
// Exactly one deletion in the whole script, inside `scrub_in` and
// downstream of all five checks. A future edit that adds a bare `rm`
// at the top level — which is what the vulnerable version was — fails
// here.
assert_eq!(
script.matches(r#"rm --one-file-system -rf -- "$p""#).count(),
1,
"the scrub deletes somewhere other than inside the validated block:\n{}",
script
);
// M1: and the flag is spelled into that one deletion rather than
// reaching it through a `$rmopt` an unsupported `rm` leaves empty.
assert!(
!script.contains("rmopt"),
"the cross-filesystem guard is optional again:\n{}",
script
);
assert_eq!(
script.matches("rm -rf").count(),
0,
"an unguarded recursive delete is back in the script:\n{}",
script
);
// The handle: after this, paths are relative to a validated inode, so
// re-pointing the directory cannot redirect the delete.
assert!(script.contains(r#"cd -P "$d" 2>/dev/null || exit 0;"#));
// 1. no symlinked component anywhere in the parent
assert!(script.contains(r#"[ "$(pwd -P)" = "$d" ] || exit 0;"#));
// 2. positive containment against a hardcoded allowlist
assert!(script.contains("/tmp|/tmp/*|/var/log|/var/log/*"));
assert!(!script.contains("/workspace"));
// 3. the parent on the same filesystem as the root — a bind mount or
// volume is not part of the writable layer and must never be
// touched.
assert!(script.contains(r#"[ -n "$rootdev" ] || exit 0;"#));
assert!(script.contains(r#"[ "$(stat -c %d . 2>/dev/null)" = "$rootdev" ] || exit 0;"#));
// 4. and *each match* on it too. Check 3 says nothing about a mount
// planted at the match itself, and `rm --one-file-system` compares
// against its own argument's device, so without this line a volume
// mounted at `/tmp/claude-x` has its contents deleted — reproduced
// against a live daemon before this existed.
assert!(script
.contains(r#"[ "$(stat -c %d -- "$p" 2>/dev/null)" = "$rootdev" ] || continue;"#));
// 5. an expansion carrying a separator means the list changed shape
assert!(script.contains(r#"case "$p" in */*|.|..) continue ;; esac;"#));
}
#[test]
fn the_scrub_script_cannot_be_steered_by_path() {
// Checks 3 and 4 are `stat` answering a question, and the image's
// `PATH` starts with three directories inside the container's own
// persisted home volume. A three-line `stat` shim planted in the first
// of them made an unshimmed-refused mount at `/var/log/apt` delete its
// whole contents, so "no `stat` means no deletion" was never the
// property that mattered. The defence is the `PATH` reset, and it has
// to be the *first* statement in the script.
let script = snapshot_scrub_script();
assert!(
script.starts_with("PATH=/usr/bin:/bin; export PATH;\n"),
"the scrub inherits the container's PATH:\n{}",
script
);
// Nothing may run before the reset takes effect.
let reset_at = script.find("export PATH;").expect("the PATH reset");
assert!(
!script[..reset_at].contains('$'),
"something is expanded before PATH is reset:\n{}",
script
);
// H3: and the tools are *not* named absolutely on top of that.
// `/usr/bin/stat` is where coreutils live on Ubuntu and not where they
// live on Alpine, which Settings → Docker → Custom accepts — so an
// absolute path turned the whole scrub into a silent no-op there while
// buying nothing the reset above had not already bought.
assert!(
!script.contains("/usr/bin/stat"),
"the scrub hardcodes a coreutils location that is not universal:\n{}",
script
);
for tool in ["stat", "du", "find", "cut", "rm"] {
assert!(
!script.contains(&format!("/usr/bin/{}", tool)),
"`{}` is called by absolute path, which fails closed and silently on any image \
that does not put it there:\n{}",
tool,
script
);
}
}
#[test]
fn the_scrub_refuses_to_run_at_all_when_a_tool_is_missing() {
// H3, the structural half. Every one of the six things the script needs
// is probed up front, and a script that cannot find one prints a marker
// the caller can tell from a total instead of printing `0`.
let script = snapshot_scrub_script();
assert!(script.contains(
r#"for t in stat rm du cut find; do command -v "$t" >/dev/null 2>&1 || missing="$missing $t"; done;"#
));
// M1: the seventh. `rm` existing is not `rm` taking the one flag that
// bounds a recursive delete run as root, and the difference used to be
// an empty variable nobody could see from the outside.
assert!(script.contains(
r#"command -v rm >/dev/null 2>&1 && { rm --one-file-system -f -- '' >/dev/null 2>&1 || missing="$missing rm-one-file-system"; };"#
));
assert!(script.contains(&format!(
r#"[ -z "$missing" ] || {{ echo "{}$missing"; exit 0; }};"#,
SCRUB_UNAVAILABLE_MARKER
)));
// The two markers must be un-confusable in both directions, since the
// caller reads whichever it finds out of one interleaved stream.
assert!(!SCRUB_UNAVAILABLE_MARKER.starts_with(SCRUB_MARKER));
assert!(!SCRUB_MARKER.starts_with(SCRUB_UNAVAILABLE_MARKER));
assert_eq!(parse_scrub_total("###TRIPLE-C-SCRUB-UNAVAILABLE stat"), None);
assert!(parse_scrub_unavailable("###TRIPLE-C-SCRUBBED 0").is_none());
}
#[test]
fn the_scrub_exec_blanks_the_dynamic_linker_hooks() {
// The exec inherits the container's env, and a project's custom env
// vars are part of it: `LD_PRELOAD` is in none of the reserved
// families, so it reaches a root exec unfiltered and injects code into
// every tool the scrub runs, `PATH` reset or not.
for key in ["LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH"] {
assert!(
SCRUB_EXEC_ENV.contains(&format!("{}=", key).as_str()),
"{} reaches the scrub exec from the container's env",
key
);
// Blanked, never given a value — an empty `LD_PRELOAD` is what
// glibc reads as "no preload".
assert!(!is_reserved_env_key(key), "the reserved list has changed shape");
}
for entry in SCRUB_EXEC_ENV {
assert!(entry.ends_with('='), "{} carries a value into the scrub", entry);
}
}
#[test]
fn the_scrub_script_hands_each_glob_over_unexpanded() {
let script = snapshot_scrub_script();
// Single-quoted at the call site: an unquoted `*` would be expanded
// against the script's own working directory before `scrub_in` ran.
assert!(script.contains("scrub_in '/tmp/claude-*' '-';"));
assert!(script.contains("scrub_in '/var/log/apt/*' '-';"));
assert!(script.contains("scrub_in '/tmp/triple-c-drops/*' '14';"));
// The parent/glob split happens in the shell, so every entry stays
// readable verbatim in the script rather than as a forked copy.
for pattern in SNAPSHOT_SCRUB_PATHS {
assert!(script.contains(pattern), "{} is not named in the script", pattern);
}
// Expanded inside the function, where the cwd is the validated
// directory, and quoted everywhere it is *used* so a filename with a
// space stays one argument.
assert!(script.contains("for p in $g; do"));
assert!(script.contains(r#"rm --one-file-system -rf -- "$p""#));
// `rm -rf /` would be catastrophic and is exactly what a botched
// interpolation produces.
assert!(!script.contains("rm -rf -- /\n"));
assert!(!script.contains(" / "));
}
/// The behavioural half of the C1 fix.
///
/// The test this replaces asserted `!script.contains("/workspace")`, which
/// the exploited version passed: containment is a property of what the
/// paths resolve to at run time. Docker is not available everywhere the
/// suite runs, so a throwaway directory tree stands in for the container
/// and `snapshot_scrub_script_under` re-anchors the real generated script
/// onto it. Linux-only because the script uses GNU `stat -c` and `du -b`,
/// which is what it will always run against inside the image.
#[cfg(target_os = "linux")]
#[test]
fn the_scrub_script_refuses_a_symlinked_parent_and_still_reclaims_a_real_one() {
use std::fs;
use std::process::Command;
// Canonicalised: a TMPDIR that is itself a symlink would trip check 1
// and make every case pass by doing nothing at all.
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
let mk = |rel: &str| fs::create_dir_all(root.join(rel)).expect("mkdir");
// The script calls its tools by absolute path, so the re-anchored copy
// needs them where it will look for them.
plant_scrub_tools(&root);
mk("tmp/triple-c-drops");
mk("var/log");
mk("var/lib/apt/lists");
mk("var/cache/apt/archives");
// Stands in for /workspace/{mount_name}: the user's own project,
// bind-mounted from the host.
mk("workspace/myproject/sub");
fs::write(root.join("workspace/myproject/precious.txt"), "do not delete").unwrap();
fs::write(root.join("workspace/myproject/sub/nested.txt"), "nor this").unwrap();
// Debris the scrub is supposed to take, so this cannot pass by
// refusing everything.
mk("tmp/claude-scratch");
fs::write(root.join("tmp/claude-scratch/blob"), vec![0u8; 64 * 1024]).unwrap();
fs::write(root.join("var/lib/apt/lists/deb.list"), vec![0u8; 32 * 1024]).unwrap();
// The attack, in the two shapes that were verified against a real
// container: a symlinked *parent* (a path component, resolved by both
// the glob and the `rm -rf`) and a symlinked *match* (safe already —
// `rm -rf -- link` unlinks the link — and pinned here so it stays so).
std::os::unix::fs::symlink(root.join("workspace/myproject"), root.join("var/log/apt"))
.unwrap();
std::os::unix::fs::symlink(
root.join("workspace/myproject"),
root.join("tmp/claude-link"),
)
.unwrap();
// The age gate on the user's own files: the one dropped a moment ago
// survives a recreation, the one from last month does not.
fs::write(root.join("tmp/triple-c-drops/fresh.txt"), "just dropped").unwrap();
fs::write(root.join("tmp/triple-c-drops/stale.txt"), vec![0u8; 8 * 1024]).unwrap();
let touched = Command::new("touch")
.arg("-d")
.arg("30 days ago")
.arg(root.join("tmp/triple-c-drops/stale.txt"))
.status()
.map(|s| s.success())
.unwrap_or(false);
let script = snapshot_scrub_script_under(&root_str);
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&script)
.output()
.expect("run the generated script");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exists = |rel: &str| root.join(rel).exists();
let survived = exists("workspace/myproject/precious.txt")
&& exists("workspace/myproject/sub/nested.txt");
let debris_gone =
!exists("tmp/claude-scratch") && !exists("var/lib/apt/lists/deb.list");
let link_removed = fs::symlink_metadata(root.join("tmp/claude-link")).is_err();
let fresh_kept = exists("tmp/triple-c-drops/fresh.txt");
let stale_gone = !exists("tmp/triple-c-drops/stale.txt");
let total = parse_scrub_total(&stdout);
fs::remove_dir_all(&root).ok();
assert!(
survived,
"the scrub followed a symlinked parent into a bind mount.\nstdout: {}\nstderr: {}\nscript:\n{}",
stdout, stderr, script
);
assert!(
debris_gone,
"the scrub stopped reclaiming anything.\nstdout: {}\nstderr: {}",
stdout, stderr
);
assert!(link_removed, "a symlinked match was left behind instead of unlinked");
assert!(fresh_kept, "a file dropped moments ago was scrubbed anyway");
if touched {
assert!(stale_gone, "an age-limited file well past its limit was kept");
}
let total = total.expect("the marker line is missing");
assert!(
total >= 96 * 1024,
"reported total {} is too small to have removed the planted debris",
total
);
}
/// Put the tools the re-anchored script resolves through its own `PATH`
/// where it will look for them.
///
/// Symlinks to the real ones, so a test can replace exactly one — which is
/// how a mount point is simulated below without the privileges no test
/// process has.
#[cfg(target_os = "linux")]
fn plant_scrub_tools(root: &std::path::Path) {
plant_scrub_tools_in(root, "usr/bin");
}
/// [`plant_scrub_tools`] into a chosen directory of the test root.
///
/// The script's `PATH` is `{root}/usr/bin:{root}/bin`, so `bin` is the
/// non-usr-merged layout — Alpine's — that H3 was a silent no-op on.
#[cfg(target_os = "linux")]
fn plant_scrub_tools_in(root: &std::path::Path, rel: &str) {
std::fs::create_dir_all(root.join(rel)).expect("mkdir the tool directory");
for tool in ["stat", "du", "find", "cut", "rm"] {
let real = ["/usr/bin", "/bin"]
.iter()
.map(|d| std::path::Path::new(d).join(tool))
.find(|c| c.exists())
.unwrap_or_else(|| panic!("no {} on this host to link", tool));
std::os::unix::fs::symlink(real, root.join(rel).join(tool))
.expect("link a tool into the test root");
}
}
/// The other half of C1: a mount at the *match* rather than at its parent.
///
/// Checks 03 validate the parent and `rm --one-file-system` compares
/// against its own argument, so with the match itself a mount root there
/// was nothing left between the scrub and the mounted filesystem's
/// contents. Confirmed end to end before the fix — `docker run -v
/// vol:/tmp/claude-x` came back with the volume empty, and the same run
/// against a host directory bound at `/workspace/../tmp/claude-x` (the
/// target the daemon builds from a mount name of `../tmp/claude-x`) emptied
/// the host directory.
///
/// A test process cannot mount anything — no `CAP_SYS_ADMIN`, and this
/// container refuses `unshare -Urm` as well — so the boundary is simulated
/// by the one thing that reports it: a `stat` that answers with a foreign
/// device for one directory. Both negative controls are asserted, so a
/// harness that had stopped being able to show the difference fails rather
/// than passing quietly.
#[cfg(target_os = "linux")]
#[test]
fn the_scrub_script_refuses_a_match_that_is_a_mount_point_and_ignores_a_shimmed_stat() {
use std::fs;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
fs::create_dir_all(root.join("tmp")).expect("mkdir");
plant_scrub_tools(&root);
// `claude-mounted` stands in for the user's project directory bound
// over a scratchpad path; `claude-real` is the debris the scrub is
// still expected to take, so nothing here can pass by refusing
// everything.
let seed = || {
fs::remove_dir_all(root.join("tmp/claude-mounted")).ok();
fs::remove_dir_all(root.join("tmp/claude-real")).ok();
fs::create_dir_all(root.join("tmp/claude-mounted/sub")).expect("mkdir");
fs::create_dir_all(root.join("tmp/claude-real")).expect("mkdir");
fs::write(root.join("tmp/claude-mounted/precious.txt"), "do not delete").unwrap();
fs::write(root.join("tmp/claude-mounted/sub/nested.txt"), "nor this").unwrap();
fs::write(root.join("tmp/claude-real/blob"), vec![0u8; 64 * 1024]).unwrap();
};
let mounted_survived = || {
root.join("tmp/claude-mounted/precious.txt").exists()
&& root.join("tmp/claude-mounted/sub/nested.txt").exists()
};
// The kernel reports a mount point as a directory whose device differs
// from its parent's. This says exactly that, for one name, and
// otherwise answers honestly.
fs::remove_file(root.join("usr/bin/stat")).expect("drop the symlink to the real stat");
fs::write(
root.join("usr/bin/stat"),
"#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in claude-mounted|*/claude-mounted) echo 999999; exit 0 ;; esac; done\nexec /usr/bin/stat \"$@\"\n",
)
.expect("overwrite the stat symlink with a stub");
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(root.join("usr/bin/stat"), fs::Permissions::from_mode(0o755))
.unwrap();
}
// A shim of the shape an agent can plant in its own home volume, first
// on `PATH`: one constant device makes every comparison agree.
let hostile = root.join("hostile");
fs::create_dir_all(&hostile).expect("mkdir");
fs::write(hostile.join("stat"), "#!/bin/sh\necho 1\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(hostile.join("stat"), fs::Permissions::from_mode(0o755)).unwrap();
}
// The shim first, then the test root's own tool directory: without the
// reset the script finds the shim, with it the script finds neither
// this `PATH` nor the shim.
let hostile_path = format!(
"{}:{}/usr/bin:/usr/bin:/bin",
hostile.display(),
root_str
);
let real = snapshot_scrub_script_under(&root_str);
// Negative control 1: the same script with check 4 deleted.
let without_match_check: String = real
.lines()
.filter(|l| !l.contains(r#"-- "$p" 2>/dev/null)" = "$rootdev" ] || continue;"#))
.map(|l| format!("{}\n", l))
.collect();
// Negative control 2: the same script with the `PATH` reset deleted,
// so its bare tool names resolve through whatever `PATH` says. This is
// what the scrub was before the reset existed, and the shim below owns
// it. (Naming the tools absolutely *also* defeated the shim — and was
// H3, because `/usr/bin/stat` is not where every image keeps `stat`.
// The reset defeats it without that failure mode: the shim lives in
// the home volume, which the reset drops off `PATH` entirely, and uid
// 1000 cannot write `/usr/bin` or `/bin`.)
let without_path_reset: String = real
.lines()
.filter(|l| !l.starts_with("PATH="))
.map(|l| format!("{}\n", l))
.collect();
let run = |script: &str, path: &str| {
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.env("PATH", path)
.output()
.expect("run the generated script")
};
seed();
let real_out = run(&real, &hostile_path);
let real_kept = mounted_survived();
let real_reclaimed = !root.join("tmp/claude-real/blob").exists();
seed();
run(&without_match_check, &hostile_path);
let no_check_kept = mounted_survived();
seed();
run(&without_path_reset, &hostile_path);
let bare_kept = mounted_survived();
fs::remove_dir_all(&root).ok();
assert!(
!no_check_kept,
"the harness cannot tell the difference: the mount-point check was removed and the \
directory survived anyway, so the assertion below proves nothing"
);
assert!(
!bare_kept,
"the harness cannot tell the difference: the shimmed `stat` was reachable and did not \
change the outcome, so PATH hardening cannot be shown either way"
);
assert!(
real_kept,
"the scrub emptied a mount planted at the match.\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&real_out.stdout),
String::from_utf8_lossy(&real_out.stderr),
);
assert!(
real_reclaimed,
"the scrub stopped reclaiming a real scratchpad next to the mount"
);
assert_eq!(
parse_scrub_total(&String::from_utf8_lossy(&real_out.stdout)).map(|t| t >= 64 * 1024),
Some(true),
"the reported total does not account for the scratchpad that was removed"
);
}
/// M1: an `rm` that cannot bound a recursive delete stops the scrub; it
/// does not turn the scrub into an unbounded one.
///
/// `--one-file-system` is the only thing between `rm -rf` run as root and a
/// mount planted *below* a match — the one position checks 04 cannot see,
/// because it is neither the parent they validate nor the match they
/// `stat`. It used to be probed into `$rmopt`, and every busybox- or
/// toybox-derived `rm` leaves that empty (BusyBox 1.38 answers
/// `unrecognized option '--one-file-system'` and exits 1, measured), so the
/// guard was absent on exactly the images that reach this code through
/// `ImageSource::Custom` — and the run still reported `Reclaimed(n)`.
///
/// The `rm` planted here answers the way busybox's does. The negative
/// control is the shape this replaces, rebuilt from the real script: it
/// deletes, which is what makes the assertion that the real script does not
/// mean anything.
#[cfg(target_os = "linux")]
#[test]
fn an_rm_without_the_cross_filesystem_guard_stops_the_scrub() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
plant_scrub_tools(&root);
let real_rm = ["/usr/bin/rm", "/bin/rm"]
.iter()
.find(|c| std::path::Path::new(c).exists())
.expect("an rm on this host")
.to_string();
fs::remove_file(root.join("usr/bin/rm")).expect("drop the symlink to the real rm");
fs::write(
root.join("usr/bin/rm"),
format!(
"#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in --one-file-system) echo \"rm: \
unrecognized option '--one-file-system'\" >&2; exit 1 ;; esac; done\nexec {rm} \
\"$@\"\n",
rm = real_rm
),
)
.expect("plant a busybox-shaped rm");
fs::set_permissions(root.join("usr/bin/rm"), fs::Permissions::from_mode(0o755)).unwrap();
let seed = || {
fs::remove_dir_all(root.join("tmp")).ok();
fs::create_dir_all(root.join("tmp/claude-scratch")).expect("mkdir");
fs::write(root.join("tmp/claude-scratch/blob"), vec![0u8; 64 * 1024]).unwrap();
};
let script = snapshot_scrub_script_under(&root_str);
// The pre-M1 shape: the flag asked for with `--help` and kept in a
// variable, which this `rm` leaves empty.
let fail_open = script
.replace(
r#"command -v rm >/dev/null 2>&1 && { rm --one-file-system -f -- '' >/dev/null 2>&1 || missing="$missing rm-one-file-system"; };"#,
"rmopt=;\nrm --one-file-system --help >/dev/null 2>&1 && rmopt=--one-file-system;",
)
.replace(r#"rm --one-file-system -rf -- "$p""#, r#"rm -rf $rmopt -- "$p""#);
assert_ne!(fail_open, script, "the control was rebuilt from stale text");
let run = |sh: &str| {
Command::new("/bin/sh")
.arg("-c")
.arg(sh)
.output()
.expect("run the generated script")
};
seed();
let control = run(&fail_open);
let control_deleted = !root.join("tmp/claude-scratch").exists();
seed();
let real = run(&script);
let real_out = String::from_utf8_lossy(&real.stdout).into_owned();
let real_kept = root.join("tmp/claude-scratch/blob").exists();
fs::remove_dir_all(&root).ok();
assert!(
control_deleted,
"the harness cannot tell the difference: the fail-open shape deleted nothing either, \
so the assertions below prove nothing.\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&control.stdout),
String::from_utf8_lossy(&control.stderr),
);
assert!(
real_kept,
"the scrub deleted as root with no cross-filesystem bound.\nstdout: {}\nstderr: {}",
real_out,
String::from_utf8_lossy(&real.stderr),
);
// And it is loud about it: a refusal must never render as the `0` a
// healthy container with nothing to clean prints.
assert_eq!(
parse_scrub_total(&real_out),
None,
"a scrub that refused to run still printed a total: {}",
real_out
);
let reason = parse_scrub_unavailable(&real_out)
.unwrap_or_else(|| panic!("no unavailable marker in: {}", real_out));
assert!(
reason.contains("--one-file-system"),
"the reason does not name the guard that is missing: {}",
reason
);
}
/// M1, the other half: the guard is attached to the delete that actually
/// runs, not merely to the script text.
///
/// The only assertion this position had was that the string
/// `rm -rf $rmopt -- "$p"` appeared in the script — which says nothing
/// about what `rm` is handed once the shell has expanded it, and an empty
/// `$rmopt` was precisely the defect. So the `rm` the script resolves
/// records its own argv here and the argv is what is asserted.
///
/// A test process cannot create the mount that would exercise the flag for
/// real — no `CAP_SYS_ADMIN`, and this container refuses `unshare -Urm` as
/// well — so "the flag is honoured at a nested boundary" is measured in
/// containers and recorded on [`snapshot_scrub_script_under`]. "The flag is
/// there at all, on every match" is this.
#[cfg(target_os = "linux")]
#[test]
fn every_delete_reaches_rm_with_the_cross_filesystem_guard_attached() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
plant_scrub_tools(&root);
let real_rm = ["/usr/bin/rm", "/bin/rm"]
.iter()
.find(|c| std::path::Path::new(c).exists())
.expect("an rm on this host")
.to_string();
let argv_log = root.join("argv");
fs::remove_file(root.join("usr/bin/rm")).expect("drop the symlink to the real rm");
fs::write(
root.join("usr/bin/rm"),
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\nexec {rm} \"$@\"\n",
log = argv_log.display(),
rm = real_rm
),
)
.expect("plant an argv-recording rm");
fs::set_permissions(root.join("usr/bin/rm"), fs::Permissions::from_mode(0o755)).unwrap();
// Two matches of the same pattern, so "the flag is on the first one"
// cannot pass for "the flag is on every one".
let seed = || {
fs::remove_file(&argv_log).ok();
fs::remove_dir_all(root.join("tmp")).ok();
for name in ["tmp/claude-a", "tmp/claude-b"] {
fs::create_dir_all(root.join(name)).expect("mkdir");
fs::write(root.join(name).join("blob"), vec![0u8; 32 * 1024]).unwrap();
}
};
// Only the deletes, not the `rm --one-file-system -f -- ''` probe.
let deletes = || -> Vec<String> {
fs::read_to_string(&argv_log)
.unwrap_or_default()
.lines()
.filter(|l| l.contains("claude-"))
.map(|l| l.to_string())
.collect()
};
let script = snapshot_scrub_script_under(&root_str);
let unguarded = script.replace(
r#"rm --one-file-system -rf -- "$p""#,
r#"rm -rf -- "$p""#,
);
assert_ne!(unguarded, script, "the control was rebuilt from stale text");
let run = |sh: &str| {
Command::new("/bin/sh")
.arg("-c")
.arg(sh)
.output()
.expect("run the generated script")
};
seed();
run(&script);
let guarded_argv = deletes();
seed();
run(&unguarded);
let unguarded_argv = deletes();
fs::remove_dir_all(&root).ok();
assert_eq!(
guarded_argv.len(),
2,
"both matches should have reached rm exactly once: {:?}",
guarded_argv
);
assert!(
unguarded_argv.len() == 2
&& unguarded_argv.iter().all(|a| !a.contains("--one-file-system")),
"the harness cannot tell the difference: stripping the flag from the script did not \
change the argv rm was handed: {:?}",
unguarded_argv
);
for argv in &guarded_argv {
assert!(
argv.starts_with("--one-file-system "),
"a delete reached rm without the cross-filesystem guard: {:?}",
guarded_argv
);
}
}
/// M2: the script cannot be steered by a shell function, because the shell
/// that runs it has no environment to import one from.
///
/// `bash` builds shell functions out of environment variables named
/// `BASH_FUNC_<name>%%`, and function lookup happens *before* `PATH` — so
/// the `PATH` reset is no defence, and `command -v` reports the function as
/// found, which walks the planted function straight through the
/// prerequisite probe. Nor is any in-script answer one: `[`, `pwd`, `cd`,
/// `command` and `unset` all import just as readily (measured on `bash`
/// 5.2), which is checks 02 and both of the things that would remove the
/// function.
///
/// The shipped image's `/bin/sh` is `dash`, which imports nothing, so this
/// is latent there — but the Dockerfile does not pin `/bin/sh` and the base
/// is inherited from `ubuntu:24.04`, and `ImageSource::Custom` takes any
/// image at all. So the inner shell is `bash` here on purpose: it stands in
/// for the image where this is not latent, and it is the only way to show
/// that what protects the script is `env -i` rather than the accident of
/// `dash` being what `/bin/sh` points at.
#[cfg(target_os = "linux")]
#[test]
fn a_planted_shell_function_cannot_answer_the_containment_checks() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let bash = ["/usr/bin/bash", "/bin/bash"]
.iter()
.find(|c| std::path::Path::new(c).exists())
.expect("a bash on this host to stand in for an image whose /bin/sh is bash")
.to_string();
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
fs::create_dir_all(root.join("tmp")).expect("mkdir");
plant_scrub_tools(&root);
// The same simulation the mount-at-the-match test uses: a `stat` that
// reports a foreign device for one name is what the kernel reports for
// a mount point, and no test process can make a real one.
fs::remove_file(root.join("usr/bin/stat")).expect("drop the symlink to the real stat");
fs::write(
root.join("usr/bin/stat"),
"#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in claude-mounted|*/claude-mounted) echo 999999; exit 0 ;; esac; done\nexec /usr/bin/stat \"$@\"\n",
)
.expect("overwrite the stat symlink with a stub");
fs::set_permissions(root.join("usr/bin/stat"), fs::Permissions::from_mode(0o755)).unwrap();
let seed = || {
fs::remove_dir_all(root.join("tmp/claude-mounted")).ok();
fs::remove_dir_all(root.join("tmp/claude-real")).ok();
fs::create_dir_all(root.join("tmp/claude-mounted/sub")).expect("mkdir");
fs::create_dir_all(root.join("tmp/claude-real")).expect("mkdir");
fs::write(root.join("tmp/claude-mounted/precious.txt"), "do not delete").unwrap();
fs::write(root.join("tmp/claude-mounted/sub/nested.txt"), "nor this").unwrap();
fs::write(root.join("tmp/claude-real/blob"), vec![0u8; 64 * 1024]).unwrap();
};
let mounted_survived = || {
root.join("tmp/claude-mounted/precious.txt").exists()
&& root.join("tmp/claude-mounted/sub/nested.txt").exists()
};
let script = snapshot_scrub_script_under(&root_str);
// Production's bootstrap with the inner shell swapped for `bash`, i.e.
// the image where `/bin/sh` is bash. Nothing else about it changes.
let as_bash = |b: &str| b.replace("/bin/sh -c", &format!("{} -c", bash));
let bootstrap = as_bash(SCRUB_BOOTSTRAP);
// Negative control: the same bootstrap without the `-i`, which is the
// one character that separates "the inner shell has no environment"
// from "the inner shell has the caller's".
let without_env_reset = bootstrap.replace("env -i ", "env ");
assert_ne!(without_env_reset, bootstrap, "the control was rebuilt from stale text");
// One function is enough to own checks 3 and 4; the rest are there to
// show the bootstrap does not care how many of them there are.
let hostile: &[(&str, &str)] = &[
("BASH_FUNC_stat%%", "() { echo 1; }"),
("BASH_FUNC_command%%", "() { echo shadowed; }"),
("BASH_FUNC_unset%%", "() { :; }"),
("BASH_FUNC_[%%", "() { :; }"),
("BASH_FUNC_pwd%%", "() { echo /; }"),
("BASH_FUNC_cd%%", "() { :; }"),
];
let run = |boot: &str, planted: &[(&str, &str)]| {
let mut c = Command::new(&bash);
c.arg("-c").arg(boot).arg("triple-c-scrub").arg(&script);
for (k, v) in planted {
c.env(k, v);
}
c.output().expect("run the bootstrap")
};
seed();
let control = run(&without_env_reset, &hostile[..1]);
let control_kept = mounted_survived();
seed();
let real = run(&bootstrap, &hostile[..1]);
let real_out = String::from_utf8_lossy(&real.stdout).into_owned();
let real_kept = mounted_survived();
let real_reclaimed = !root.join("tmp/claude-real/blob").exists();
seed();
let swarm = run(&bootstrap, hostile);
let swarm_out = String::from_utf8_lossy(&swarm.stdout).into_owned();
let swarm_kept = mounted_survived();
let swarm_reclaimed = !root.join("tmp/claude-real/blob").exists();
fs::remove_dir_all(&root).ok();
assert!(
!control_kept,
"the harness cannot tell the difference: a planted `stat` function reached an inner \
shell with the environment intact and the mount survived anyway, so the assertions \
below prove nothing.\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&control.stdout),
String::from_utf8_lossy(&control.stderr),
);
assert!(
real_kept,
"a planted shell function answered the containment checks.\nstdout: {}\nstderr: {}",
real_out,
String::from_utf8_lossy(&real.stderr),
);
assert!(real_reclaimed, "the scrub stopped reclaiming a real scratchpad");
assert_eq!(
parse_scrub_total(&real_out).map(|t| t >= 64 * 1024),
Some(true),
"the reported total does not account for the scratchpad that was removed: {}",
real_out
);
// And with every builtin the script leans on shadowed at once, it is
// still the script's own answers that decide.
assert!(
swarm_kept && swarm_reclaimed,
"a shadowed `[`/`pwd`/`cd`/`command`/`unset` changed what the scrub did.\nstdout: {}\nstderr: {}",
swarm_out,
String::from_utf8_lossy(&swarm.stderr),
);
assert_eq!(parse_scrub_total(&swarm_out).map(|t| t >= 64 * 1024), Some(true));
}
/// M2, the structural half: the exec hands the script over as an argument
/// to a shell that was started with no environment.
#[test]
fn the_scrub_exec_starts_its_shell_with_an_empty_environment() {
let script = snapshot_scrub_script();
let cmd = scrub_exec_cmd(&script);
assert_eq!(cmd.len(), 5);
assert_eq!(cmd[0], "/bin/sh");
assert_eq!(cmd[1], "-c");
assert_eq!(cmd[2], SCRUB_BOOTSTRAP);
// As `$1`, never spliced into the bootstrap: the script is full of
// single quotes and it runs `rm -rf` as root, so a nested-quoting bug
// there is the whole class of defect this module keeps having.
assert_eq!(cmd[4], script);
assert!(
!cmd[2].contains("scrub_in"),
"the script is interpolated into the bootstrap: {}",
cmd[2]
);
assert!(cmd[2].contains(r#"/usr/bin/env -i PATH=/usr/bin:/bin /bin/sh -c "$1""#));
// H3's lesson kept: `env` is no more usr-merged than `stat` is, and a
// hardcoded path that is simply absent must not be the quiet answer.
assert!(cmd[2].contains(r#"/bin/env -i PATH=/usr/bin:/bin /bin/sh -c "$1""#));
assert!(cmd[2].contains("case $? in 127)"));
// Everything the *outer* shell evaluates has to be a reserved word, a
// parameter expansion, or a command word containing a `/` — those are
// the three things a planted function cannot reach, and `bash` refuses
// to import a function whose name has a `/` in it.
assert_eq!(
cmd[2].matches("env -i").count(),
cmd[2].matches("/env -i").count(),
"the bootstrap calls `env` by a bare name: {}",
cmd[2]
);
assert_eq!(
cmd[2].matches("sh -c").count(),
cmd[2].matches("/sh -c").count(),
"the bootstrap calls `sh` by a bare name: {}",
cmd[2]
);
// And the one caller builds its argv here rather than assembling its
// own. Everything above is a property of `scrub_exec_cmd`, and a call
// site that quietly went back to `/bin/sh -c script` would satisfy all
// of it while running the script in the environment this exists to
// keep away from it. The needle is assembled rather than written out
// so that this line is not itself the match.
let needle = format!("{}(&{}())", "scrub_exec_cmd", "snapshot_scrub_script");
assert!(
include_str!("container.rs").contains(&needle),
"the scrub exec no longer goes through the bootstrap"
);
}
/// H3, the behavioural half: a base image whose coreutils are not under
/// `/usr/bin`.
///
/// Settings → Docker → **Custom** takes any image name, and on Alpine
/// `stat` and `rm` are in `/bin`. Measured in a real `alpine:latest`
/// container against the same seeded tree: the absolute-path script printed
/// `###TRIPLE-C-SCRUBBED 0` and left every planted file where it was, while
/// the `PATH`-resolved one reclaimed 73738 bytes. Both halves are asserted
/// here — the negative control is the script this replaced, rebuilt by
/// putting the `/usr/bin/` prefixes back — so a regression cannot pass by
/// the harness quietly losing the ability to tell them apart.
#[cfg(target_os = "linux")]
#[test]
fn the_scrub_finds_its_tools_when_coreutils_are_not_usr_merged() {
use std::fs;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
// The whole point: `bin`, and deliberately no `usr/bin` at all.
plant_scrub_tools_in(&root, "bin");
assert!(!root.join("usr/bin").exists());
let seed = || {
fs::remove_dir_all(root.join("tmp")).ok();
fs::create_dir_all(root.join("tmp/claude-scratch")).expect("mkdir");
fs::write(root.join("tmp/claude-scratch/blob"), vec![0u8; 64 * 1024]).unwrap();
};
let script = snapshot_scrub_script_under(&root_str);
// The pre-fix shape, rebuilt: every tool named absolutely under a
// `/usr/bin` this root does not have.
let absolute: String = script
.replace(r#"$(stat "#, &format!("$({}/usr/bin/stat ", root_str))
.replace(
"\nrm --one-file-system -rf ",
&format!("\n{}/usr/bin/rm --one-file-system -rf ", root_str),
)
.replace(
"&& { rm --one-file-system",
&format!("&& {{ {}/usr/bin/rm --one-file-system", root_str),
)
.replace("$(du -sb", &format!("$({}/usr/bin/du -sb", root_str))
.replace("| cut -f1", &format!("| {}/usr/bin/cut -f1", root_str))
.replace("$(find ", &format!("$({}/usr/bin/find ", root_str))
.lines()
// The probe is what makes the pre-fix shape *loud*; the control has
// to be the quiet version it replaced, so drop it.
.filter(|l| !l.starts_with("for t in ") && !l.starts_with(r#"[ -z "$missing" ]"#))
.map(|l| format!("{}\n", l))
.collect();
let run = |sh: &str| {
Command::new("/bin/sh")
.arg("-c")
.arg(sh)
.output()
.expect("run the generated script")
};
seed();
let before = run(&absolute);
let before_out = String::from_utf8_lossy(&before.stdout).into_owned();
let before_kept = root.join("tmp/claude-scratch/blob").exists();
seed();
let after = run(&script);
let after_out = String::from_utf8_lossy(&after.stdout).into_owned();
let after_gone = !root.join("tmp/claude-scratch").exists();
fs::remove_dir_all(&root).ok();
assert!(
before_kept,
"the harness cannot tell the difference: the absolute-path script reclaimed on a \
root with no /usr/bin, so this proves nothing.\nstdout: {}",
before_out
);
assert_eq!(
parse_scrub_total(&before_out),
Some(0),
"the control is supposed to be the *silent* failure — a clean-looking zero"
);
assert!(
after_gone,
"the scrub is still a no-op where coreutils are in /bin.\nstdout: {}\nstderr: {}",
after_out,
String::from_utf8_lossy(&after.stderr)
);
assert!(
parse_scrub_total(&after_out).unwrap_or(0) >= 64 * 1024,
"reported total {:?} does not account for the planted debris",
parse_scrub_total(&after_out)
);
assert!(parse_scrub_unavailable(&after_out).is_none());
}
/// H3's third silence: a scrub that could not run must not read as one that
/// ran and found nothing.
#[cfg(target_os = "linux")]
#[test]
fn a_scrub_that_cannot_find_its_tools_says_so_instead_of_reporting_zero() {
use std::fs;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
fs::create_dir_all(root.join("tmp/claude-scratch")).expect("mkdir");
fs::write(root.join("tmp/claude-scratch/blob"), vec![0u8; 64 * 1024]).unwrap();
// No tools planted anywhere: `PATH` names two directories that do not
// exist, which is what a base image the scrub cannot run in looks like.
let script = snapshot_scrub_script_under(&root_str);
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&script)
.output()
.expect("run the generated script");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let debris_kept = root.join("tmp/claude-scratch/blob").exists();
fs::remove_dir_all(&root).ok();
assert!(debris_kept, "the scrub deleted with no tools to validate with");
assert_eq!(
parse_scrub_total(&stdout),
None,
"a scrub that could not run still printed a total: {}",
stdout
);
let reason = parse_scrub_unavailable(&stdout)
.unwrap_or_else(|| panic!("no unavailable marker in: {}", stdout));
assert!(reason.contains("stat"), "the reason does not name what is missing: {}", reason);
// And the outcome the caller derives from it is not a success.
assert_ne!(ScrubOutcome::Unavailable, ScrubOutcome::Reclaimed(0));
assert!(!ScrubOutcome::Unavailable.commit_log_suffix().contains("MB"));
}
/// The byte total counts what a partly failed `rm` actually removed.
///
/// `rm --one-file-system` walking into a match with a mount planted below
/// it removes everything else and exits non-zero. The old
/// `rm … && acc=$((acc + sz))` dropped the whole subtree from the figure,
/// so the one case the `--one-file-system` probe exists for was also the
/// one that under-reported. A mount cannot be created by a test process, so
/// the partial failure is produced the only other way it happens: an `rm`
/// that removes part of its argument and returns non-zero.
#[cfg(target_os = "linux")]
#[test]
fn the_byte_total_counts_what_a_partly_failed_rm_removed() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir");
let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()));
let root_str = root.to_str().expect("a UTF-8 temp path").to_string();
plant_scrub_tools(&root);
fs::create_dir_all(root.join("tmp/claude-partial")).expect("mkdir");
fs::write(root.join("tmp/claude-partial/gone"), vec![0u8; 64 * 1024]).unwrap();
fs::write(root.join("tmp/claude-partial/keep"), vec![0u8; 8 * 1024]).unwrap();
let real_rm = ["/usr/bin/rm", "/bin/rm"]
.iter()
.find(|c| std::path::Path::new(c).exists())
.expect("an rm on this host")
.to_string();
fs::remove_file(root.join("usr/bin/rm")).expect("drop the symlink to the real rm");
fs::write(
root.join("usr/bin/rm"),
format!(
"#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in claude-partial) {rm} -rf -- \
./claude-partial/gone; exit 1 ;; esac; done\nexec {rm} \"$@\"\n",
rm = real_rm
),
)
.expect("plant a partly-failing rm");
fs::set_permissions(root.join("usr/bin/rm"), fs::Permissions::from_mode(0o755)).unwrap();
let script = snapshot_scrub_script_under(&root_str);
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&script)
.output()
.expect("run the generated script");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let gone = !root.join("tmp/claude-partial/gone").exists();
let kept = root.join("tmp/claude-partial/keep").exists();
let total = parse_scrub_total(&stdout);
fs::remove_dir_all(&root).ok();
assert!(
gone && kept,
"the harness cannot tell the difference: the planted rm did not fail partly \
(gone={}, kept={})\nstdout: {}",
gone,
kept,
stdout
);
let total = total.expect("the marker line is missing");
assert!(
total >= 64 * 1024,
"a partly failed rm was counted as having reclaimed {} bytes",
total
);
// And what survived is not counted as reclaimed.
assert!(total < 72 * 1024, "the total {} counts bytes that are still there", total);
}
#[test]
fn the_scrub_total_is_read_back_from_the_marker_line() {
assert_eq!(
parse_scrub_total("some noise\n###TRIPLE-C-SCRUBBED 4812345\n"),
Some(4812345)
);
// Nothing reclaimed is a real answer and must not read as a failure.
assert_eq!(parse_scrub_total("###TRIPLE-C-SCRUBBED 0"), Some(0));
// No marker means the script never got to the end — a different thing
// from reclaiming nothing, and the caller logs it differently.
assert_eq!(parse_scrub_total("sh: du: not found"), None);
assert_eq!(parse_scrub_total(""), None);
// H3: and "could not run" is a third thing again, told apart by its
// own marker rather than by a total that looks healthy.
assert_eq!(
parse_scrub_total("###TRIPLE-C-SCRUB-UNAVAILABLE stat rm"),
None
);
let reason = parse_scrub_unavailable("noise\n###TRIPLE-C-SCRUB-UNAVAILABLE stat rm\n")
.expect("the unavailable marker");
assert!(reason.contains("stat, rm"), "{}", reason);
assert!(parse_scrub_unavailable("###TRIPLE-C-SCRUBBED 41").is_none());
assert!(parse_scrub_unavailable("").is_none());
}
#[test]
fn only_a_completed_scrub_reports_bytes() {
// M11: a stopped container is a skip, not a failure, and every outcome
// that is not a completed run has no figure to contribute at all —
// which is a stronger statement than the "contributes zero" this used
// to make, and the reason the zero stopped being printed.
assert_eq!(ScrubOutcome::NotRunning.commit_log_suffix(), "");
assert!(!ScrubOutcome::TimedOut.commit_log_suffix().contains("MB"));
assert!(!ScrubOutcome::Failed.commit_log_suffix().contains("MB"));
assert!(!ScrubOutcome::Unavailable.commit_log_suffix().contains("MB"));
assert!(ScrubOutcome::Reclaimed(4096)
.commit_log_suffix()
.contains("MB"));
assert_ne!(ScrubOutcome::NotRunning, ScrubOutcome::Failed);
assert_ne!(ScrubOutcome::Unavailable, ScrubOutcome::Reclaimed(0));
}
#[test]
fn a_skipped_scrub_reports_no_reclaim_figure() {
// Every migration logged "0.00 MB dropped by the pre-commit scrub":
// the migration path scrubs the container while it is still running and
// stops it before committing, so this second call has nothing to exec
// into. A zero there reads as a scrub that ran and found nothing.
assert_eq!(ScrubOutcome::NotRunning.commit_log_suffix(), "");
assert!(!ScrubOutcome::TimedOut.commit_log_suffix().contains("MB"));
assert!(!ScrubOutcome::Failed.commit_log_suffix().contains("MB"));
// A scrub that really ran still reports, zero included — but H3 is
// that "0.00 MB dropped" was also what a scrub that *could not run*
// printed, and the two must not render the same. A genuine zero says it
// ran; the broken one says it could not.
let ran = ScrubOutcome::Reclaimed(0).commit_log_suffix();
assert!(ran.contains("ran"), "{}", ran);
assert!(!ran.contains("MB"), "a zero that reads as a figure: {}", ran);
let broken = ScrubOutcome::Unavailable.commit_log_suffix();
assert!(broken.contains("could not run"), "{}", broken);
assert_ne!(ran, broken);
assert!(ScrubOutcome::Reclaimed(2 * 1_048_576)
.commit_log_suffix()
.contains("2.00 MB"));
}
#[test]
fn the_scrub_cannot_hold_a_snapshot_up_indefinitely() {
// M12: the scrub is a `du -sb` plus an `rm -rf` over a tree a running
// agent may still be writing to, on the critical path of every
// recreate with the UI on "Saving container state…" and no cancel.
assert!(SCRUB_TIMEOUT.as_secs() >= 30, "too tight to survive a slow but healthy scrub");
assert!(SCRUB_TIMEOUT.as_secs() <= 300, "long enough that a user would force-quit first");
}
#[test]
fn every_container_is_created_with_a_bounded_log() {
let cfg = capped_log_config();
assert_eq!(cfg.typ.as_deref(), Some("json-file"));
let config = cfg.config.expect("a json-file driver with no config is unbounded");
assert_eq!(config.get("max-size").map(String::as_str), Some("10m"));
assert_eq!(config.get("max-file").map(String::as_str), Some("3"));
}
// ── Claude Code settings.json (Part B) ───────────────────────────────────
fn settings_json(s: Option<&ClaudeCodeSettings>, sandbox: bool) -> serde_json::Value {
serde_json::from_str(&build_claude_code_settings_json(s, sandbox))
.expect("the payload must be valid JSON — the entrypoint pipes it into jq")
}
/// The five keys that used to be emitted only when non-default, plus the
/// sandbox block that already knew better.
const MANAGED_SETTINGS_KEYS: &[&str] = &[
"tui",
"effortLevel",
"autoScrollEnabled",
"showThinkingSummaries",
"viewMode",
"awaySummaryEnabled",
"sandbox",
];
#[test]
fn a_project_can_turn_a_globally_enabled_setting_back_off() {
// The whole reason the booleans are `Option<bool>`. Under the old
// `if p.x { true } else { g.x }` merge there was no project value that
// could produce `false` here.
let global = ClaudeCodeSettings {
focus_mode: Some(true),
env_scrub: Some(true),
prompt_caching_1h: Some(true),
..Default::default()
};
let project = ClaudeCodeSettings {
focus_mode: Some(false),
..Default::default()
};
let merged = merge_claude_code_settings(Some(&global), Some(&project)).unwrap();
assert_eq!(merged.focus_mode, Some(false), "project off must win");
// Untouched project fields still inherit.
assert_eq!(merged.env_scrub, Some(true));
assert_eq!(merged.prompt_caching_1h, Some(true));
// And it has to survive into what the container actually receives.
let payload = build_claude_code_settings_json(Some(&merged), false);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert!(
v.get("viewMode").is_some_and(|m| m.is_null()),
"viewMode should be cleared, got {:?}",
v.get("viewMode")
);
}
#[test]
fn inherit_and_deliberate_off_fingerprint_differently() {
// If these collided, switching a project from "inherit" to an explicit
// "off" would not recreate the container and the change would silently
// not apply.
let inherit = ClaudeCodeSettings { focus_mode: None, ..Default::default() };
let off = ClaudeCodeSettings { focus_mode: Some(false), ..Default::default() };
assert_ne!(
compute_claude_code_settings_fingerprint(Some(&inherit), false),
compute_claude_code_settings_fingerprint(Some(&off), false),
);
}
#[test]
fn split_payload_never_emits_a_null_to_an_older_entrypoint() {
// An existing project recreates from its own snapshot, which carries
// whatever entrypoint it was built with. An older one merges with a
// plain `.[0] * .[1]`, so a null reaching it would be written into the
// user's settings.json verbatim instead of clearing the key.
let payload = build_claude_code_settings_json(None, false);
assert!(
payload.contains("null"),
"precondition: the unsplit payload uses null to mean delete"
);
let (set, clear) = split_claude_code_settings_payload(&payload);
let set_val: serde_json::Value = serde_json::from_str(&set).unwrap();
for (k, v) in set_val.as_object().unwrap() {
assert!(!v.is_null(), "key {} reached the merge half as a null", k);
}
let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap();
let cleared: Vec<&str> = clear_val
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
// The four whose neutral state is genuinely "unset".
for k in ["tui", "effortLevel", "viewMode", "awaySummaryEnabled"] {
assert!(cleared.contains(&k), "{} should be cleared, not pinned", k);
}
}
#[test]
fn split_payload_keeps_every_key_exactly_once() {
// Nothing may be dropped or duplicated between the two halves, or a
// managed key would silently stop being asserted.
let settings = ClaudeCodeSettings {
tui_mode: Some("fullscreen".to_string()),
focus_mode: Some(true),
..Default::default()
};
let payload = build_claude_code_settings_json(Some(&settings), true);
let original: serde_json::Value = serde_json::from_str(&payload).unwrap();
let (set, clear) = split_claude_code_settings_payload(&payload);
let set_val: serde_json::Value = serde_json::from_str(&set).unwrap();
let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap();
let mut seen: Vec<String> = set_val
.as_object()
.unwrap()
.keys()
.cloned()
.chain(
clear_val
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string()),
)
.collect();
seen.sort();
let mut expected: Vec<String> = original.as_object().unwrap().keys().cloned().collect();
expected.sort();
assert_eq!(seen, expected);
}
#[test]
fn turning_a_setting_off_clears_it_rather_than_omitting_it() {
// This is the whole bug. `~/.claude/settings.json` lives on a persisted
// volume and the entrypoint merges into it, so a key that is merely
// *absent* when the setting is off leaves the previous on-value in
// place — the setting could never be turned back off short of a
// destructive Reset.
let on = ClaudeCodeSettings {
tui_mode: Some("fullscreen".to_string()),
effort: Some("xhigh".to_string()),
auto_scroll_disabled: Some(true),
focus_mode: Some(true),
show_thinking_summaries: Some(true),
session_recap_disabled: Some(true),
..Default::default()
};
let hot = settings_json(Some(&on), true);
assert_eq!(hot["tui"], serde_json::json!("fullscreen"));
assert_eq!(hot["effortLevel"], serde_json::json!("xhigh"));
assert_eq!(hot["autoScrollEnabled"], serde_json::json!(false));
assert_eq!(hot["showThinkingSummaries"], serde_json::json!(true));
assert_eq!(hot["viewMode"], serde_json::json!("focus"));
assert_eq!(hot["awaySummaryEnabled"], serde_json::json!(false));
assert_eq!(hot["sandbox"]["enabled"], serde_json::json!(true));
// Now everything back to default. Every key must still be *present*,
// carrying either its neutral value or a null the entrypoint deletes.
let cold = settings_json(Some(&ClaudeCodeSettings::default()), false);
for key in MANAGED_SETTINGS_KEYS {
assert!(
cold.get(*key).is_some(),
"{} is missing when the setting is off, so a stale on-value survives the merge",
key
);
}
assert_eq!(cold["tui"], serde_json::Value::Null);
assert_eq!(cold["effortLevel"], serde_json::Value::Null);
assert_eq!(cold["autoScrollEnabled"], serde_json::json!(true));
assert_eq!(cold["showThinkingSummaries"], serde_json::json!(false));
assert_eq!(cold["viewMode"], serde_json::Value::Null);
assert_eq!(cold["awaySummaryEnabled"], serde_json::Value::Null);
assert_eq!(cold["sandbox"]["enabled"], serde_json::json!(false));
}
#[test]
fn no_settings_struct_at_all_still_asserts_every_key() {
// "This project has no Claude Code settings" is not "say nothing" —
// the file on the config volume may still hold a previous project
// configuration's values.
let cold = settings_json(None, false);
for key in MANAGED_SETTINGS_KEYS {
assert!(cold.get(*key).is_some(), "{} is missing with no settings struct", key);
}
}
#[test]
fn the_settings_payload_uses_the_key_names_claude_code_actually_reads() {
let s = ClaudeCodeSettings {
effort: Some("high".to_string()),
focus_mode: Some(true),
..Default::default()
};
let json = settings_json(Some(&s), false);
// `effort` and `focusMode` were both invented; Claude Code reads
// `effortLevel` and `viewMode`.
assert!(json.get("effort").is_none(), "`effort` is not a Claude Code setting");
assert!(json.get("focusMode").is_none(), "`focusMode` is not a Claude Code setting");
assert_eq!(json["effortLevel"], serde_json::json!("high"));
assert_eq!(json["viewMode"], serde_json::json!("focus"));
}
#[test]
fn tui_can_be_pinned_to_the_classic_renderer_as_well_as_left_automatic() {
// Three distinct states; "automatic" is not "classic".
let auto = settings_json(Some(&ClaudeCodeSettings::default()), false);
assert_eq!(auto["tui"], serde_json::Value::Null);
let classic = settings_json(
Some(&ClaudeCodeSettings {
tui_mode: Some("default".to_string()),
..Default::default()
}),
false,
);
assert_eq!(classic["tui"], serde_json::json!("default"));
}
#[test]
fn a_project_that_never_touched_session_recap_leaves_it_alone() {
// Claude Code's recap is on by default, so the zero value of the field
// has to be "don't interfere". Getting this backwards would have
// silently disabled recaps for every existing project.
let untouched = ClaudeCodeSettings::default();
assert_eq!(untouched.session_recap_disabled, None);
assert_eq!(
settings_json(Some(&untouched), false)["awaySummaryEnabled"],
serde_json::Value::Null
);
}
#[test]
fn a_disabled_setting_changes_the_recreation_fingerprint() {
// `container_needs_recreation` is label-based and never diffs env, so
// the settings only reach a container if the fingerprint moves.
let on = ClaudeCodeSettings {
focus_mode: Some(true),
..Default::default()
};
let off = ClaudeCodeSettings::default();
assert_ne!(
compute_claude_code_settings_fingerprint(Some(&on), false),
compute_claude_code_settings_fingerprint(Some(&off), false),
);
let recap_off = ClaudeCodeSettings {
session_recap_disabled: Some(true),
..Default::default()
};
assert_ne!(
compute_claude_code_settings_fingerprint(Some(&recap_off), false),
compute_claude_code_settings_fingerprint(Some(&off), false),
);
}
#[test]
fn the_claude_code_env_vars_are_reserved_from_custom_env() {
for key in [
"CLAUDE_CODE_NO_FLICKER",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB",
"ENABLE_PROMPT_CACHING_1H",
] {
assert!(is_reserved_env_key(key), "{} must not be hand-settable", key);
assert!(is_reserved_env_key(&key.to_lowercase()));
}
}
#[test]
fn every_claude_code_env_var_is_emitted_on_every_create() {
// `docker commit` bakes env into the snapshot image, so a name that
// goes missing when its setting is off keeps whatever the image
// carries. All four must appear whatever the settings say.
for settings in [None, Some(&ClaudeCodeSettings::default())] {
let emitted = claude_code_env_vars(settings);
let names: Vec<&str> = emitted
.iter()
.map(|entry| entry.split_once('=').expect("every entry is KEY=VALUE").0)
.collect();
for expected in [
"CLAUDE_CODE_NO_FLICKER",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB",
"ENABLE_PROMPT_CACHING_1H",
] {
assert!(names.contains(&expected), "{} was not emitted", expected);
}
}
}
#[test]
fn the_neutral_state_of_the_overriding_env_vars_is_empty_not_zero() {
// Both of these outrank a setting the user can change from inside the
// container, so their "off" has to be silence, not an instruction.
let vars = claude_code_env_vars(Some(&ClaudeCodeSettings::default()));
assert!(vars.contains(&"CLAUDE_CODE_NO_FLICKER=".to_string()));
assert!(vars.contains(&"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=".to_string()));
// These two have no documented meaning for `0`, so it is safe to say.
assert!(vars.contains(&"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0".to_string()));
assert!(vars.contains(&"ENABLE_PROMPT_CACHING_1H=0".to_string()));
}
#[test]
fn turning_the_session_recap_off_actually_forces_it_off() {
// The whole point of B3: `=1` when enabled was a no-op against a
// feature that was already on, and there was no off path at all.
let off = ClaudeCodeSettings {
session_recap_disabled: Some(true),
..Default::default()
};
assert!(claude_code_env_vars(Some(&off))
.contains(&"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=0".to_string()));
}
#[test]
fn the_tui_choice_reaches_the_env_var_that_outranks_the_setting() {
let fullscreen = ClaudeCodeSettings {
tui_mode: Some("fullscreen".to_string()),
..Default::default()
};
assert!(claude_code_env_vars(Some(&fullscreen))
.contains(&"CLAUDE_CODE_NO_FLICKER=1".to_string()));
let classic = ClaudeCodeSettings {
tui_mode: Some("default".to_string()),
..Default::default()
};
assert!(claude_code_env_vars(Some(&classic))
.contains(&"CLAUDE_CODE_NO_FLICKER=0".to_string()));
}
}