Merge branch 'feat/disk-and-settings' into integration/round-1

This commit is contained in:
2026-08-23 08:35:51 -07:00
19 changed files with 1469 additions and 112 deletions
+1 -1
View File
@@ -518,7 +518,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
### Frontend — settings, terminal and hooks
+22 -7
View File
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
## Current coverage (v0.3.0)
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
| Key | Surfaced as |
|---|---|
| `tui` | TUI Mode select (`fullscreen`) |
| `effort` | Effort Level select (`low`/`medium`/`high`) |
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
| `focusMode` | Focus Mode toggle |
| `showThinkingSummaries` | Thinking Summaries toggle |
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
merges into it, so a key merely omitted when its control goes off left the previous
on-value in place forever.
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
`CLAUDE_CODE_*` vars via the Env Vars modal.
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
create *including* their off value, because `docker commit` bakes a container's env into
the snapshot image: a value written once would otherwise ride that snapshot into every
future container. That also makes them Triple-C's to own, so all four are reserved names
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
@@ -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)?;
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
import type { ClaudeCodeSettings } from "../../lib/types";
function renderEditor(settings: ClaudeCodeSettings | null) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<ClaudeCodeSettingsEditor settings={settings} disabled={false} onSave={onSave} />,
);
return onSave;
}
describe("ClaudeCodeSettingsEditor", () => {
it("shows the two default-on settings as on for a project that never touched them", () => {
// Claude Code's session recap and fullscreen auto-scroll are both on by
// default, and the fields behind them store the *disabled* sense. A toggle
// rendered straight from the field would tell every existing user their
// recap is off.
renderEditor(null);
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
});
it("stores the disabled sense when an inverted toggle is switched off", () => {
const onSave = renderEditor(null);
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: true }),
);
});
it("collapses back to null once every setting is at its default again", () => {
// `null` is what tells the backend this project adds nothing over the
// global settings, so the round trip has to land exactly back on it.
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(null);
});
it("offers the classic renderer as a choice distinct from automatic", () => {
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
// different, and previously unreachable, instruction.
const onSave = renderEditor(null);
const tui = screen.getByLabelText("TUI mode");
expect(
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["", "default", "fullscreen"]);
fireEvent.change(tui, { target: { value: "default" } });
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
});
it("offers every effort level Claude Code accepts", () => {
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
});
});
@@ -16,7 +16,7 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
auto_scroll_disabled: false,
focus_mode: false,
show_thinking_summaries: false,
enable_session_recap: false,
session_recap_disabled: false,
env_scrub: false,
prompt_caching_1h: false,
};
@@ -28,16 +28,25 @@ function isAllDefaults(s: ClaudeCodeSettings): boolean {
s.auto_scroll_disabled === false &&
s.focus_mode === false &&
s.show_thinking_summaries === false &&
s.enable_session_recap === false &&
s.session_recap_disabled === false &&
s.env_scrub === false &&
s.prompt_caching_1h === false
);
}
/**
* Two of Claude Code's settings are **on by default**, so the field behind them
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
* that is what makes an untouched project mean "leave Claude Code alone"
* rather than "the user turned this off". `invert` is what lets those still
* read as an ordinary on/off switch here: the toggle shows the feature's state,
* the field stores the deviation from the default.
*/
const BOOLEAN_FIELDS: {
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
label: string;
hint: string;
invert?: boolean;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
@@ -46,14 +55,16 @@ const BOOLEAN_FIELDS: {
hint: "Shows Claude's thinking process as summaries.",
},
{
key: "enable_session_recap",
key: "session_recap_disabled",
label: "Session recap",
hint: "Provides context when returning to a session.",
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
invert: true,
},
{
key: "auto_scroll_disabled",
label: "Auto-scroll disabled",
hint: "Disables auto-scroll when in fullscreen TUI mode.",
label: "Auto-scroll",
hint: "Follows new output to the bottom in fullscreen rendering.",
invert: true,
},
{
key: "env_scrub",
@@ -95,9 +106,16 @@ export default function ClaudeCodeSettingsEditor({
</p>
)}
{/*
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
the renderer for itself, which is not the same as pinning the classic
one and the key is now always written (or explicitly deleted), so
"Automatic" has to be selectable rather than merely being what you get
when nothing is emitted.
*/}
<SwitchRow
label="TUI mode"
hint="Enables flicker-free alt-screen rendering."
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
control={
<select
value={local.tui_mode ?? ""}
@@ -106,7 +124,8 @@ export default function ClaudeCodeSettingsEditor({
disabled={disabled}
className={selectClass}
>
<option value="">Default</option>
<option value="">Automatic</option>
<option value="default">Classic</option>
<option value="fullscreen">Fullscreen</option>
</select>
}
@@ -127,11 +146,12 @@ export default function ClaudeCodeSettingsEditor({
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
</select>
}
/>
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
<SwitchRow
key={key}
label={label}
@@ -139,9 +159,11 @@ export default function ClaudeCodeSettingsEditor({
control={
<Toggle
label={label}
checked={local[key]}
checked={invert ? !local[key] : local[key]}
disabled={disabled}
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
onChange={(v) =>
apply({ [key]: invert ? !v : v } as Partial<ClaudeCodeSettings>)
}
/>
}
/>
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
</>
}
>
{/*
Everything remove_project() destroys, named. It removes the container,
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
the triple-c-snapshot-{id} image and the project's keychain secrets so
an accurate warning has to reach past "the config volume". The last
sentence is the reassuring half and matters just as much: project folders
are bind mounts from the host and nothing here touches them.
*/}
<p className="text-[13px] text-[var(--text-secondary)]">
Are you sure you want to remove{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
delete the container, config volume, and stored credentials.
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
its container, both of its volumes and its saved container image so the home
directory, the Claude login and config, installed skills, session transcripts,
scheduled tasks and any stored credentials all go with it.
</p>
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
Your project folders on this machine are mounted in, not copied, and are left
untouched.
</p>
</Modal>
);
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
</div>
{task && (
/*
An edit is `add` then `remove` (see `update_scheduled_task`), and
`triple-c-scheduler`'s remove now reaps the task's log directory
so on a current container the old logs are gone, not merely filed
under the old id, which is what this used to promise.
It is deliberately not stated as a certainty. `/usr/local/bin` only
changes on base-image migration or Reset, so a project still running
an older base image carries the older scheduler, whose remove leaves
the log directory behind. "Assume they go with it" is true in both
worlds and spares the user a paragraph about which one they are in.
*/
<p className="text-xs text-[var(--text-secondary)]">
The scheduler has no edit command, so saving re-creates this task under a new id and
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
the old id.
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
with it.
</p>
)}
+8 -1
View File
@@ -134,12 +134,19 @@ export interface OpenAiCompatibleConfig {
}
export interface ClaudeCodeSettings {
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
tui_mode: string | null;
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
effort: string | null;
auto_scroll_disabled: boolean;
/** Written as `viewMode: "focus"`. */
focus_mode: boolean;
show_thinking_summaries: boolean;
enable_session_recap: boolean;
/**
* Turns the session recap **off**. Held in the disabled sense because Claude
* Code's recap is on by default see the Rust doc on `ClaudeCodeSettings`.
*/
session_recap_disabled: boolean;
env_scrub: boolean;
prompt_caching_1h: boolean;
}
+19
View File
@@ -1,5 +1,24 @@
FROM ubuntu:24.04
# ── Provenance labels ────────────────────────────────────────────────────────
# Without these the base image carries no labels at all, and
# `sweep_orphaned_snapshots` (app/src-tauri/src/docker/container.rs) filters on
# `dangling=true` **and** `triple-c.managed=true` — so a superseded base image,
# left untagged when a newer build claims `triple-c-sandbox:latest`, could never
# match and was never collected. ~11.9 GB of stranded base images was measured
# on one developer's daemon this way.
#
# `triple-c.managed=true` is what makes them sweepable. Note that Docker merges
# an image's labels into the containers created from it and `docker commit`
# copies a container's labels onto the image, so this value also arrives on
# every container and every snapshot — which is harmless, because
# `create_container` writes the same key explicitly anyway.
#
# `triple-c.base=true` marks *this* image specifically, so a base image can be
# told apart from a project snapshot without parsing repository names.
LABEL triple-c.managed=true
LABEL triple-c.base=true
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
# Avoid interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive
+24 -9
View File
@@ -405,23 +405,38 @@ install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}"
unset VPN_SUPPORT_ENABLED
# ── Claude Code settings ────────────────────────────────────────────────────
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
# keys). Creates the file if it doesn't exist. These control TUI mode, effort
# level, focus mode, thinking summaries, and other CLI behavior.
# Apply the managed Claude Code settings to ~/.claude/settings.json, keeping
# every key the user set inside the container.
#
# `settings.json` lives on the persisted triple-c-claude-config-{id} volume, so
# it outlives the container and a plain `.[0] * .[1]` merge could only ever
# *add*. That is what made every one of these settings one-way: switching one
# off in Triple-C omitted its key, the merge preserved the old on-value, and the
# setting stayed on until a destructive Reset. So the payload from Rust states
# the whole managed key set on every start, and a JSON **null** in it means
# "delete this key" rather than "merge a null" — which is how a setting whose
# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
# `awaySummaryEnabled`) is turned back off without pinning a stand-in value.
# See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs.
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
SETTINGS_FILE="/home/claude/.claude/settings.json"
mkdir -p /home/claude/.claude
if [ -f "$SETTINGS_FILE" ]; then
# Merge: existing settings + new settings (new keys override on conflict)
MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
# One code path for "file exists" and "file doesn't": seeding an empty
# object means the null-deleting merge below runs in both cases, so a fresh
# container never gets a settings.json with literal nulls written into it.
[ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE"
MERGED=$(jq -s '
.[0] as $current
| .[1] as $managed
| ($managed | with_entries(select(.value != null))) as $set
| ($managed | to_entries | map(select(.value == null) | [.key])) as $clear
| ($current * $set) | delpaths($clear)
' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
else
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
fi
else
printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
fi
chown claude:claude "$SETTINGS_FILE"
chmod 600 "$SETTINGS_FILE"
unset CLAUDE_CODE_SETTINGS_JSON
+22
View File
@@ -20,6 +20,27 @@ generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
# Delete a task's log directory, called wherever a task stops existing.
#
# The task file is the only index of a task, so a log directory that outlives
# it is unreachable — `logs --id` needs an id nothing can hand you any more —
# and it sits on the home volume for the life of the project. The moment of
# removal is the last point at which we still know what to delete.
#
# The `rm -rf` deserves paranoia, so the id is re-validated here rather than
# trusted from the caller: the pattern rejects an empty id (which would expand
# to $LOGS_DIR itself), anything containing `/` or `.` (which could climb out
# of $LOGS_DIR), and a leading `-`. It matches validate_task_id() in
# app/src-tauri/src/commands/inspect_commands.rs. Always one literal path,
# never a glob.
reap_task_logs() {
local id="${1:-}"
[[ "$id" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 0
local dir="${LOGS_DIR:?}/${id}"
[ -d "$dir" ] || return 0
rm -rf -- "$dir"
}
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
@@ -292,6 +313,7 @@ cmd_remove() {
local name
name=$(jq -r '.name' "$task_file")
rm -f "$task_file"
reap_task_logs "$id"
rebuild_crontab
echo "Removed task '$name' ($id)"
}
+60
View File
@@ -125,6 +125,37 @@ fi
echo "=== Exit code: $EXIT_CODE ==="
} >> "$LOG_FILE"
# ── Cap the size of this run's log ──────────────────────────────────────────
# `claude -p` output is unbounded — a task told to walk a large tree can emit
# hundreds of megabytes in one run — and the pruning below counts *files*, not
# bytes, so twenty logs of any size are twenty logs. One chatty task can
# therefore fill the home volume, which is also where ~/.claude and the OAuth
# credential live.
#
# The tail is the half worth keeping: `claude -p` writes its answer at the end,
# and the footer just appended carries the exit code that `status` and the app
# both grep for. So an oversize log is rewritten as a marker line plus its last
# MAX_LOG_BYTES rather than being deleted or capped from the front. This runs
# before the notification below so the summary is taken from the capped file.
#
# Best effort throughout: the run's real result is already recorded, so a
# failure here must not change the exit status. Note that `run` may be tailing
# this file — it has already streamed everything up to here, and nothing is
# appended after this point, so replacing the inode is invisible to it.
MAX_LOG_BYTES=$(( 5 * 1024 * 1024 ))
LOG_BYTES=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)
if [ "${LOG_BYTES:-0}" -gt "$MAX_LOG_BYTES" ]; then
TRUNC_FILE="${LOG_FILE}.trunc"
if {
echo "=== Log truncated: $(( LOG_BYTES - MAX_LOG_BYTES )) bytes dropped from the start (cap ${MAX_LOG_BYTES} bytes) ==="
tail -c "$MAX_LOG_BYTES" "$LOG_FILE"
} > "$TRUNC_FILE" 2>/dev/null; then
mv -f "$TRUNC_FILE" "$LOG_FILE" 2>/dev/null || rm -f "$TRUNC_FILE"
else
rm -f "$TRUNC_FILE"
fi
fi
# ── Write notification ──────────────────────────────────────────────────────
mkdir -p "$NOTIFICATIONS_DIR"
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
@@ -176,6 +207,35 @@ if [ "$LOG_COUNT" -gt 20 ]; then
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
fi
# ── Reap log dirs of tasks that no longer exist ─────────────────────────────
# `triple-c-scheduler remove` deletes a task's log dir with the task, but a
# one-time task deletes its own task file above, so `remove` can never be run
# for it — nothing knows the id any more — and its directory would sit on the
# home volume forever. This is the sweep for that case.
#
# Deliberately delayed rather than done in the cleanup above: the run that just
# finished has only just written the sole record of itself, `run` and the app's
# Automation tab may still be tailing it, and `logs --id` keeps working for a
# task whose file is gone. So a dir is reaped only once nothing in it has been
# touched for LOG_RETENTION_DAYS, and never while a run is publishing state for
# that id. The sweep rides on task runs, so a container whose only task was
# one-time keeps that one directory until something else runs.
#
# Same paranoia as reap_task_logs() in triple-c-scheduler: the id comes from a
# directory name and is re-validated before it is used to build an `rm -rf`
# path, so no empty or path-bearing name can reach beyond $LOGS_DIR.
LOG_RETENTION_DAYS=7
for ORPHAN_DIR in "$LOGS_DIR"/*/; do
[ -d "$ORPHAN_DIR" ] || continue
ORPHAN_ID=$(basename "$ORPHAN_DIR")
[[ "$ORPHAN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || continue
[ -f "${TASKS_DIR}/${ORPHAN_ID}.json" ] && continue
[ -f "${RUNNING_DIR}/${ORPHAN_ID}.json" ] && continue
# Anything modified inside the window keeps the whole directory.
[ -n "$(find "$ORPHAN_DIR" -mmin "-$(( LOG_RETENTION_DAYS * 1440 ))" -print -quit 2>/dev/null)" ] && continue
rm -rf -- "${LOGS_DIR:?}/${ORPHAN_ID}"
done
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
if [ "$NOTIFY_COUNT" -gt 50 ]; then