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
@@ -1200,9 +1200,14 @@ pub async fn add_scheduled_task(
/// * **In that order**, so a rejected `add` leaves the original untouched
/// rather than deleting a prompt the user cannot get back. The cost is a
/// sub-second window in which both tasks are in the crontab.
/// * The task therefore gets a **new id**. Its old log directory
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
/// UI warns about this before saving.
/// * The task therefore gets a **new id**, and its old log directory
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
/// scheduler reaps a task's logs when the task stops existing, because
/// nothing can name that id again afterwards. The UI warns before saving.
/// (A project still running an older base image carries the older
/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
/// copy is deliberately written for the case that loses data.)
/// * `enabled` is carried over explicitly, because `add` always creates an
/// enabled task and silently re-enabling a task the user had switched off
/// would schedule a run they did not ask for.
@@ -863,7 +863,7 @@ pub async fn confirm_migration(
// waiting for the project's next recreation would leave it lying around
// indefinitely.
tauri::async_runtime::spawn(async {
crate::docker::sweep_orphaned_snapshots().await;
crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
});
Ok(())
@@ -957,6 +957,16 @@ pub async fn rollback_migration(
let _ = mig::untag_image(&rollback_ref).await;
migration_store::clear_staging(&project_id)?;
migration_store::clear(&project_id)?;
// Retagging above moved `:latest` off the *migrated* snapshot, and the
// container that was built from it was removed a few lines up — so a
// multi-gigabyte image is sitting there untagged and unreferenced with
// nothing else in the app that would ever look at it again. The confirm
// path sweeps for exactly this reason; rolling back orphans just as much
// and did not.
tauri::async_runtime::spawn(async {
crate::docker::sweep_orphaned_snapshots_logged("after migration rollback").await;
});
emit_progress(
&app_handle,
&project_id,
@@ -459,7 +459,7 @@ pub async fn start_project_container(
// just this one, so recreations that happened before the sweep
// existed are cleaned up too.
tauri::async_runtime::spawn(async {
docker::sweep_orphaned_snapshots().await;
docker::sweep_orphaned_snapshots_logged("after recreation").await;
});
new_id
+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()));
}
}
+324 -6
View File
@@ -712,13 +712,76 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
)
.await
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
let id = created.id;
let result = run_throwaway_inner(&id).await;
// From here on the container's removal is owned by a guard rather than by
// the statement that used to sit after the await below. A plain statement
// only runs if this future is *polled to completion*: an `Err(...)?` was
// already handled, but a **dropped** future — the app quitting mid-flight,
// a timeout, any `select!` that loses — skipped it silently and left a
// container behind holding a multi-gigabyte base image open. That image is
// then unsweepable (removal is deliberately unforced) and there is nothing
// in the UI that would ever mention it.
let guard = ProbeContainerGuard::new(created.id);
if let Err(e) = docker
let result = run_throwaway_inner(guard.id()).await;
// The happy path still removes it *synchronously*, so a caller that goes on
// to `docker rmi` the image it probed does not race the removal.
guard.remove_now().await;
result
}
/// Owns the lifetime of a probe container.
///
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
/// safety net for the abnormal one: it cannot await, so it hands the removal to
/// a detached task. That covers a dropped future while the process lives; it
/// cannot cover the process dying, which is what
/// [`reap_probe_containers`] is for.
struct ProbeContainerGuard {
id: String,
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
armed: bool,
}
impl ProbeContainerGuard {
fn new(id: String) -> Self {
Self { id, armed: true }
}
fn id(&self) -> &str {
&self.id
}
async fn remove_now(mut self) {
self.armed = false;
remove_probe_container(&self.id).await;
}
}
impl Drop for ProbeContainerGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let id = std::mem::take(&mut self.id);
log::warn!("Probe container {} was abandoned; removing it in the background", id);
tauri::async_runtime::spawn(async move {
remove_probe_container(&id).await;
});
}
}
/// Force-remove one probe container. Missing is success — the point is that the
/// container is gone.
async fn remove_probe_container(id: &str) {
let Ok(docker) = get_docker() else {
return;
};
match docker
.remove_container(
&id,
id,
Some(RemoveContainerOptions {
force: true,
v: true,
@@ -727,10 +790,57 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
)
.await
{
log::warn!("Failed to remove probe container {}: {}", id, e);
Ok(())
| Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => {}
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
}
}
result
/// Remove probe containers left behind by a previous run of the app.
///
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
/// crash, but until now nothing ever went looking. One leftover probe pins the
/// base image it was created from — several gigabytes that
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
/// touch, with no way for the user to find out why.
///
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
/// right now it belongs to this process — and this runs before any migration
/// can be started, so there is none to interrupt.
pub async fn reap_probe_containers() {
let Ok(docker) = get_docker() else {
return;
};
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"label".to_string(),
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
)]);
let containers = match docker
.list_containers(Some(bollard::container::ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
{
Ok(list) => list,
Err(e) => {
log::warn!("Could not list leftover probe containers: {}", e);
return;
}
};
for c in containers {
if let Some(id) = c.id {
log::info!("Removing leftover migration probe container {}", id);
remove_probe_container(&id).await;
}
}
}
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
@@ -910,6 +1020,145 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
}
/// How long a rollback pin may sit with no migration record behind it before
/// [`reap_stale_migration_pins`] drops the tag.
///
/// Two weeks, chosen to be far longer than anyone deliberates over a base
/// update and far shorter than "forever", which is what it was.
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
///
/// `None` for anything that is not one of ours — a tag that merely *starts*
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
/// rather than guessed at, because the consequence of guessing wrong is
/// deleting the only copy of somebody's system layer.
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let stamp = tag.strip_prefix("pre-migration-")?;
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
Some(naive.and_utc())
}
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
///
/// `None` when the reference is not a snapshot repo at all.
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
let (repo, tag) = split_image_ref(reference);
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
if project_id.is_empty() {
return None;
}
Some((project_id, tag))
}
/// Whether a rollback pin is safe to drop, given how old it is and whether the
/// project it belongs to still has a migration record.
///
/// Pure so the decision can be tested without a daemon. The order of the two
/// conditions is the point: **a pin whose migration is still awaiting
/// confirmation is never reaped at any age**, because it is the only copy of
/// the rollback target and the user has not yet said they are happy with the
/// new base.
pub fn pin_is_reapable(
tag: &str,
has_migration_record: bool,
now: &chrono::DateTime<chrono::Utc>,
) -> bool {
if has_migration_record {
return false;
}
let Some(created) = parse_rollback_tag(tag) else {
return false;
};
(*now - created).num_days() >= STALE_PIN_MAX_AGE_DAYS
}
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
/// claims any more, so the images behind them become sweepable.
///
/// ## Why this scans tags instead of reading the records
///
/// Every other path to a rollback pin starts from
/// `migration_store::load`, and `load` reports an unparseable state file as
/// *absent* — so a single corrupt record used to strand a 412 GB image that no
/// code could ever name again. Confirming or rolling back both remove the
/// record and drop the tag together, so a `pre-migration-*` tag with no record
/// beside it is by definition one that lost its owner: a crash between the two,
/// a record that was deleted by hand, or the corrupt-file case.
///
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
/// corrupt record aside (see `migration_store::load`) is what stops that case
/// from being permanently invisible here too.
///
/// ## Why it only untags
///
/// Dropping the tag turns the image dangling, and it is already labelled
/// `triple-c.managed=true` because `docker commit` created it — so
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
/// safety conditions, with the daemon's "still in use by a container" refusal
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
pub async fn reap_stale_migration_pins() -> usize {
use bollard::image::ListImagesOptions;
let Ok(docker) = get_docker() else {
return 0;
};
// `reference` matches against `repo:tag`, so this asks the daemon for
// exactly the shape [`rollback_tag`] produces and nothing else.
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
log::warn!("Could not list rollback pins: {}", e);
return 0;
}
};
let now = chrono::Utc::now();
let mut reaped = 0usize;
for summary in images {
for reference in &summary.repo_tags {
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
continue;
};
// Filesystem presence, not `load`: a record we cannot parse must
// still count as "somebody may want this back".
let has_record =
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
if !pin_is_reapable(&tag, has_record, &now) {
continue;
}
match untag_image(reference).await {
Ok(()) => {
log::info!(
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it for {} days",
reference,
summary.size as f64 / 1_073_741_824.0,
STALE_PIN_MAX_AGE_DAYS,
);
reaped += 1;
}
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
}
}
}
reaped
}
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
pub fn split_image_ref(image: &str) -> (String, String) {
match image.rsplit_once(':') {
@@ -1622,4 +1871,73 @@ mod tests {
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
}
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
chrono::NaiveDate::from_ymd_opt(y, m, d)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
}
#[test]
fn a_rollback_tag_round_trips_through_its_parser() {
let made = at(2026, 3, 14);
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
}
#[test]
fn only_a_real_rollback_tag_parses() {
assert_eq!(parse_rollback_tag("latest"), None);
assert_eq!(parse_rollback_tag("pre-migration-"), None);
// Looks like ours but carries no timestamp we produced. Guessing here
// would mean deleting the only copy of somebody's system layer.
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
}
#[test]
fn a_snapshot_reference_yields_its_project_id() {
assert_eq!(
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
);
// Not ours: a base image, and a repo that merely shares a prefix.
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
}
#[test]
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
// The one rule that cannot bend: while a migration record exists, this
// image is the only copy of the rollback target and the user has not
// yet said they are happy on the new base.
let ancient = rollback_tag(&at(2020, 1, 1));
assert!(!pin_is_reapable(&ancient, true, &at(2026, 8, 23)));
}
#[test]
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
let made = at(2026, 8, 1);
let tag = rollback_tag(&made);
assert!(!pin_is_reapable(&tag, false, &at(2026, 8, 2)));
assert!(!pin_is_reapable(
&tag,
false,
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS - 1))
));
assert!(pin_is_reapable(
&tag,
false,
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
));
}
#[test]
fn a_tag_we_cannot_date_is_left_alone() {
assert!(!pin_is_reapable("pre-migration-handmade", false, &at(2026, 8, 23)));
assert!(!pin_is_reapable("latest", false, &at(2026, 8, 23)));
}
}
+24
View File
@@ -235,6 +235,30 @@ pub fn run() {
}
}
// ── Startup disk housekeeping ────────────────────────────────
// Until now the only sweep ran *after* a recreation, so a user who
// simply stopped launching a project kept its orphaned snapshot
// layers forever, and anything a crash left behind (a probe
// container pinning a base image, a rollback pin whose migration
// record is gone) had no path back at all. All three are
// read-mostly and finish in well under a second on an idle daemon,
// but they are detached anyway: housekeeping must never delay the
// window appearing, and a daemon that is not running yet is a
// logged warning rather than a failed start.
//
// Ordering matters. Probes are removed first because a probe holds
// an image open and the sweep will not force; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
tauri::async_runtime::spawn(async {
crate::docker::reap_probe_containers().await;
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
}
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
});
// Auto-start web terminal server if enabled in settings
let settings = settings_store_setup.get();
if settings.web_terminal.enabled {
+24 -6
View File
@@ -86,24 +86,42 @@ impl PermissionMode {
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ClaudeCodeSettings {
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
/// one. All three are distinct — "let it choose" is not "classic".
#[serde(default)]
pub tui_mode: Option<String>,
/// Effort level: None = default, Some("low"|"medium"|"high")
/// Saved `/effort` level: `None` = unset, otherwise one of
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
#[serde(default)]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
/// zero value of this field has to mean "leave it on".
#[serde(default)]
pub auto_scroll_disabled: bool,
/// Enable focus mode (collapsed tool output)
/// Collapse tool output to one-line summaries. Written to settings.json as
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
#[serde(default)]
pub focus_mode: bool,
/// Show thinking summaries in responses
#[serde(default)]
pub show_thinking_summaries: bool,
/// Enable session recap when returning to a session
/// Turn the session recap **off**.
///
/// Held in the disabled sense for the same reason as `auto_scroll_disabled`,
/// and the rename from the old `enable_session_recap` is load-bearing rather
/// than cosmetic. Claude Code's recap is on by default, so the old field was
/// inverted: switching it on was a no-op and switching it off did nothing at
/// all. Reusing the name with the opposite meaning would have read every
/// stored `enable_session_recap: false` — which is what every project that
/// never touched the control holds — as "the user turned the recap off" and
/// silently disabled it for all of them. A new name lets the old key be
/// ignored, which lands every existing project on the correct default.
#[serde(default)]
pub enable_session_recap: bool,
pub session_recap_disabled: bool,
/// Strip credentials from subprocess environments
#[serde(default)]
pub env_scrub: bool,
+30 -2
View File
@@ -49,6 +49,16 @@ fn sanitize(project_id: &str) -> String {
/// Read a project's migration state. `Ok(None)` means no migration is in
/// flight; an unparseable file is treated the same way (and logged) rather than
/// blocking every future migration on a corrupt record.
///
/// **A corrupt record is moved aside, not merely ignored.** Reporting "absent"
/// while leaving the file in place strands the rollback pin it describes: the
/// `:pre-migration-*` tag holding a 412 GB image stays on disk, no code path
/// can find it again (every one of them starts here and is told there is no
/// migration), and the leftover file goes on making the project look like it
/// has a migration in flight to anything that checks for the file rather than
/// parsing it — including [`has_record`], which the pin reaper relies on.
/// Renaming to `.bak` follows the same convention as `projects.json`: the
/// user's bytes are kept, but they stop pinning gigabytes.
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
let path = state_path(project_id)?;
if !path.exists() {
@@ -59,16 +69,34 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
match serde_json::from_str::<MigrationState>(&data) {
Ok(state) => Ok(Some(state)),
Err(e) => {
let backup = path.with_extension("json.bak");
let moved = fs::rename(&path, &backup);
log::error!(
"Failed to parse migration state for project {}: {} — treating as absent",
"Failed to parse migration state for project {}: {} — treating as absent{}",
project_id,
e
e,
match moved {
Ok(()) => format!(" and moved the record to {}", backup.display()),
Err(ref e) => format!(" (could not move the record aside: {})", e),
}
);
Ok(None)
}
}
}
/// Whether a project has a migration record on disk *at all*, without parsing
/// it.
///
/// The pin reaper needs "is this project's rollback image still somebody's only
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
/// question on its own — it deliberately reports a corrupt record as absent —
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
/// what keeps the two answers from disagreeing forever.
pub fn has_record(project_id: &str) -> Result<bool, String> {
Ok(state_path(project_id)?.exists())
}
/// Atomically write a project's migration state.
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
let path = state_path(project_id)?;