Stop Docker disk growth, and fix the Claude Code settings that never worked

Two independent sets of fixes.

## Disk: stop the growth, no UI this round

The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.

* **Scrub the writable layer before every commit** (`docker/container.rs`,
  `SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
  are still free to drop is before the commit that captures them. Measured on
  one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
  `/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
  no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
  lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
  leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
  `/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
  and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
  mounts. Failure is a log line — a scrub must never block a snapshot.

* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
  anywhere, so containers ran on the daemon's unbounded `json-file` default.
  Deliberately *not* wired into `container_needs_recreation`: participating
  would recreate every project once, and a recreation costs a commit, which is
  the thing being fixed. Picked up on the next natural recreation.

* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
  no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
  pair provably could not match one — ~11.9 GB observed stranded. Stamping
  `triple-c.managed=true` is the whole fix; the sweep needed no change.
  `create_container` writes the new `triple-c.base` key explicitly empty, or
  Docker's label inheritance plus `docker commit` would make every snapshot
  claim to be a base image. `force: false` stays, and now says why.

* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
  (a probe pins an image the unforced sweep then refuses), pins second, sweep
  last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
  the report away — `reclaimed_bytes`, `failed` and `unavailable` included.

* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
  migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
  age-reaped by scanning the tag pattern rather than trusting the state file —
  `migration_store::load` reports an unparseable record as absent, which
  stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
  record aside so `has_record` is trustworthy. A pin whose migration is still
  awaiting confirmation is never reaped at any age. The probe container's
  removal was a plain statement after an await, so a dropped future (an app quit
  mid-migration) leaked a container pinning a multi-gigabyte image; it is a
  `Drop` guard now, with `reap_probe_containers` for the case where the process
  itself dies.

* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
  directory, and the task runner appended uncapped `claude -p` output.

* **Fix the delete copy.** It said "the container, config volume, and stored
  credentials"; it removes *both* volumes and the snapshot image.

No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.

## Claude Code settings: two invented keys, one inverted default, one sticky bug

Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.

* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
  was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
  does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
  `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
  could never turn the recap *off*. The field is renamed to
  `session_recap_disabled` rather than reused: reusing the name with the
  opposite meaning would have read every stored `enable_session_recap: false` —
  which is every project that never touched the control — as "the user turned
  this off".
* **The stickiness, which is the important one.** Keys were emitted only when
  non-default, and the entrypoint *merges* into a settings.json on a persisted
  volume, so switching a setting off omitted its key, the merge preserved the
  stale on-value, and the setting stayed on until a destructive Reset. The fix
  already existed in the same file — the sandbox block is emitted
  unconditionally for exactly this reason — and is now applied to all five keys.
  A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
  `awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
  because a stand-in value is not neutral: `tui: "default"` pins the classic
  renderer where unset lets Claude Code choose, and `viewMode: "default"`
  overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
  bakes container env into the snapshot image, so a `=1` written once rode it
  forever. All four are now emitted on every create, extracted into
  `claude_code_env_vars` and unit tested. Two use an empty value for "off"
  rather than `0`, because they outrank a setting the user can change from
  inside their own container and Triple-C's default must not overrule a
  `/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
  which the always-emitted key makes both necessary and possible.

`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.

Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:35:10 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit dd2894cc60
19 changed files with 1469 additions and 112 deletions
+763 -57
View File
@@ -218,6 +218,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
/// which is what keeps the sweep away from the user's own images.
const LABEL_MANAGED: &str = "triple-c.managed";
/// 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.
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
@@ -235,6 +241,15 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"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
@@ -633,7 +648,7 @@ fn merge_claude_code_settings(
auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled },
focus_mode: if p.focus_mode { true } else { g.focus_mode },
show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries },
enable_session_recap: if p.enable_session_recap { true } else { g.enable_session_recap },
session_recap_disabled: if p.session_recap_disabled { true } else { g.session_recap_disabled },
env_scrub: if p.env_scrub { true } else { g.env_scrub },
prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h },
})
@@ -660,7 +675,7 @@ fn compute_claude_code_settings_fingerprint(
format!("{}", s.auto_scroll_disabled),
format!("{}", s.focus_mode),
format!("{}", s.show_thinking_summaries),
format!("{}", s.enable_session_recap),
format!("{}", s.session_recap_disabled),
format!("{}", s.env_scrub),
format!("{}", s.prompt_caching_1h),
];
@@ -674,34 +689,164 @@ fn compute_claude_code_settings_fingerprint(
}
}
/// Build the settings.json content for Claude Code.
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
/// Always emits a `sandbox.enabled` key reflecting the current per-project
/// toggle so that flipping it off in triple-c overrides any prior on-state
/// stored in the persisted settings.json (which lives in a named volume).
/// 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 { "0" } else { "" }
),
format!(
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB={}",
if s.env_scrub { "1" } else { "0" }
),
format!(
"ENABLE_PROMPT_CACHING_1H={}",
if s.prompt_caching_1h { "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 five 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,
) -> Option<String> {
) -> 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();
if let Some(s) = settings {
if let Some(ref tui) = s.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui));
}
if let Some(ref effort) = s.effort {
map.insert("effort".to_string(), serde_json::json!(effort));
}
if s.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
}
// `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),
);
// Documented default `false`.
map.insert(
"showThinkingSummaries".to_string(),
serde_json::json!(s.show_thinking_summaries),
);
// `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 {
map.insert("focusMode".to_string(), serde_json::json!(true));
}
if s.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
}
}
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 {
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
@@ -719,11 +864,7 @@ fn build_claude_code_settings_json(
};
map.insert("sandbox".to_string(), sandbox_obj);
if map.is_empty() {
None
} else {
Some(serde_json::Value::Object(map).to_string())
}
serde_json::Value::Object(map).to_string()
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
@@ -1320,31 +1461,20 @@ pub async fn create_container(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
if let Some(ref cc) = merged_cc_settings {
// Env-var-based settings (these are read directly by Claude Code)
if cc.tui_mode.as_deref() == Some("fullscreen") {
env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string());
}
if cc.enable_session_recap {
env_vars.push("CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1".to_string());
}
if cc.env_scrub {
env_vars.push("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1".to_string());
}
if cc.prompt_caching_1h {
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
}
}
// 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 (written by the entrypoint).
// Always invoked so per-project sandbox state is injected even when no
// ClaudeCodeSettings struct is present.
if let Some(settings_json) = build_claude_code_settings_json(
merged_cc_settings.as_ref(),
project.sandbox_mode_enabled,
) {
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
}
// 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.
env_vars.push(format!(
"CLAUDE_CODE_SETTINGS_JSON={}",
build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)
));
let mut mounts: Vec<Mount> = Vec::new();
@@ -1571,6 +1701,14 @@ pub async fn create_container(
// 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());
}
@@ -1581,6 +1719,7 @@ pub async fn create_container(
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,
@@ -1618,6 +1757,36 @@ pub async fn create_container(
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
@@ -1768,6 +1937,155 @@ chmod 600 "$HOME/.aws/credentials""#;
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, and the
/// three globs are anchored to `/tmp` specifically:
///
/// * `/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 the three patterns share their prefix, so neither can be selected
/// even by accident.
/// * 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.
///
/// A unit test pins the list, 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.
"/tmp/claude-*",
// Files drag-dropped into a terminal, staged by
// `commands/terminal_commands.rs` at up to 256 MiB each. Nothing in the
// repo deletes them.
"/tmp/triple-c-drops/*",
// One PNG per pasted image, from the same module. Also never deleted.
"/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",
];
/// 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 ";
/// The `/bin/sh` program run inside the container to perform the scrub.
///
/// Built here rather than inline so a test can read it. The path list is
/// interpolated **unquoted** on the `for` line, which is the whole point: the
/// shell expands the three globs there. An unmatched glob expands to itself,
/// the `[ -e ]` guard then fails, and the entry is skipped — so a pattern that
/// matches nothing is a no-op rather than an `rm` of a literal path.
/// Inside the loop `$p` is quoted, so a filename containing whitespace is one
/// argument.
fn snapshot_scrub_script() -> String {
format!(
r#"total=0
for p in {paths}; do
[ -e "$p" ] || continue
sz=$(du -sb "$p" 2>/dev/null | cut -f1)
case "$sz" in ''|*[!0-9]*) sz=0 ;; esac
rm -rf -- "$p" 2>/dev/null && total=$((total + sz))
done
echo "{marker}$total"
exit 0
"#,
paths = SNAPSHOT_SCRUB_PATHS.join(" "),
marker = SCRUB_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.
fn parse_scrub_total(output: &str) -> Option<u64> {
output
.lines()
.rev()
.find_map(|line| line.trim().strip_prefix(SCRUB_MARKER)?.trim().parse().ok())
}
/// 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.
///
/// **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. Note that one caller (the pre-swap commit in
/// `migrate_project_to_base`) has already *stopped* the container, so `docker
/// exec` legitimately fails there — that path simply commits unscrubbed.
pub async fn scrub_writable_layer(container_id: &str) -> u64 {
let script = snapshot_scrub_script();
let cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script];
match crate::docker::exec::exec_oneshot_as(container_id, "root", cmd, Vec::new()).await {
Ok((output, _exit_code)) => match parse_scrub_total(&output) {
Some(bytes) => {
if bytes > 0 {
log::info!(
"Pre-commit scrub of container {} reclaimed {:.2} MB before it could be committed",
container_id,
bytes as f64 / 1_048_576.0
);
}
bytes
}
None => {
log::warn!(
"Pre-commit scrub of container {} did not report a total; committing anyway. Output: {}",
container_id,
output.trim()
);
0
}
},
Err(e) => {
log::warn!(
"Pre-commit scrub of container {} could not run ({}); committing anyway",
container_id,
e
);
0
}
}
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal.
@@ -1795,10 +2113,21 @@ chmod 600 "$HOME/.aws/credentials""#;
///
/// 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.
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.
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()),
@@ -1890,15 +2219,30 @@ fn orphan_sweep_filters() -> HashMap<String, Vec<String>> {
/// `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 committed.
/// `docker commit` copies the container's labels onto the image, which is what
/// makes the label a reliable mark of provenance. The user's own dangling
/// images are none of our business.
/// * **`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.
@@ -1969,6 +2313,38 @@ pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport {
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)]
@@ -3119,4 +3495,334 @@ mod tests {
};
assert!(blind.left_something_behind());
}
// ── Pre-commit scrub (A1) ────────────────────────────────────────────────
#[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.
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 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_scrub_script_expands_globs_but_quotes_the_match() {
let script = snapshot_scrub_script();
// Unquoted on the `for` line — that is what makes the shell expand the
// globs at all.
assert!(script.contains("for p in /tmp/claude-* /tmp/triple-c-drops/*"));
// Quoted everywhere it is *used*, so a filename with a space is one
// argument and not two paths.
assert!(script.contains(r#"[ -e "$p" ] || continue"#));
assert!(script.contains(r#"rm -rf -- "$p""#));
// `rm -rf /` would be catastrophic and is exactly what a botched
// interpolation produces.
assert!(!script.contains("rm -rf -- /\n"));
assert!(!script.contains(" / "));
}
#[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);
}
// ── Container log rotation (A2) ──────────────────────────────────────────
#[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 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: true,
focus_mode: true,
show_thinking_summaries: true,
session_recap_disabled: 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: 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!(!untouched.session_recap_disabled);
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: 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: 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: 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()));
}
}