Merge branch 'feat/disk-and-settings' into integration/round-1
This commit is contained in:
@@ -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/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/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/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
|
### Frontend — settings, terminal and hooks
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
|
|||||||
|
|
||||||
## Current coverage (v0.3.0)
|
## 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 |
|
| Key | Surfaced as |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tui` | TUI Mode select (`fullscreen`) |
|
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||||
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||||
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||||
| `focusMode` | Focus Mode toggle |
|
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||||
| `showThinkingSummaries` | Thinking Summaries toggle |
|
| `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`) |
|
| `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`,
|
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_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,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
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
|
/// * **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
|
/// 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.
|
/// sub-second window in which both tasks are in the crontab.
|
||||||
/// * The task therefore gets a **new id**. Its old log directory
|
/// * The task therefore gets a **new id**, and its old log directory
|
||||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||||
/// UI warns about this before saving.
|
/// 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` is carried over explicitly, because `add` always creates an
|
||||||
/// enabled task and silently re-enabling a task the user had switched off
|
/// enabled task and silently re-enabling a task the user had switched off
|
||||||
/// would schedule a run they did not ask for.
|
/// 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
|
// waiting for the project's next recreation would leave it lying around
|
||||||
// indefinitely.
|
// indefinitely.
|
||||||
tauri::async_runtime::spawn(async {
|
tauri::async_runtime::spawn(async {
|
||||||
crate::docker::sweep_orphaned_snapshots().await;
|
crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -957,6 +957,16 @@ pub async fn rollback_migration(
|
|||||||
let _ = mig::untag_image(&rollback_ref).await;
|
let _ = mig::untag_image(&rollback_ref).await;
|
||||||
migration_store::clear_staging(&project_id)?;
|
migration_store::clear_staging(&project_id)?;
|
||||||
migration_store::clear(&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(
|
emit_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&project_id,
|
&project_id,
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ pub async fn start_project_container(
|
|||||||
// just this one, so recreations that happened before the sweep
|
// just this one, so recreations that happened before the sweep
|
||||||
// existed are cleaned up too.
|
// existed are cleaned up too.
|
||||||
tauri::async_runtime::spawn(async {
|
tauri::async_runtime::spawn(async {
|
||||||
docker::sweep_orphaned_snapshots().await;
|
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||||
});
|
});
|
||||||
|
|
||||||
new_id
|
new_id
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
|
|||||||
/// which is what keeps the sweep away from the user's own images.
|
/// which is what keeps the sweep away from the user's own images.
|
||||||
const LABEL_MANAGED: &str = "triple-c.managed";
|
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_"];
|
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
|
|
||||||
/// Exact env var names Triple-C manages itself. Not covered by
|
/// Exact env var names Triple-C manages itself. Not covered by
|
||||||
@@ -235,6 +241,15 @@ const RESERVED_ENV_EXACT: &[&str] = &[
|
|||||||
"MISSION_CONTROL_ENABLED",
|
"MISSION_CONTROL_ENABLED",
|
||||||
"VPN_SUPPORT_ENABLED",
|
"VPN_SUPPORT_ENABLED",
|
||||||
"TRIPLE_C_PERMISSION_MODE",
|
"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,
|
CLAUDE_OAUTH_TOKEN_ENV,
|
||||||
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
|
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
|
||||||
// above; they are listed explicitly so that a future narrowing of the
|
// 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 },
|
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 },
|
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 },
|
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 },
|
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 },
|
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.auto_scroll_disabled),
|
||||||
format!("{}", s.focus_mode),
|
format!("{}", s.focus_mode),
|
||||||
format!("{}", s.show_thinking_summaries),
|
format!("{}", s.show_thinking_summaries),
|
||||||
format!("{}", s.enable_session_recap),
|
format!("{}", s.session_recap_disabled),
|
||||||
format!("{}", s.env_scrub),
|
format!("{}", s.env_scrub),
|
||||||
format!("{}", s.prompt_caching_1h),
|
format!("{}", s.prompt_caching_1h),
|
||||||
];
|
];
|
||||||
@@ -674,34 +689,164 @@ fn compute_claude_code_settings_fingerprint(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the settings.json content for Claude Code.
|
/// The four Claude Code env vars the settings editor drives, as `KEY=VALUE`.
|
||||||
/// 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
|
/// **All four are emitted on every create, including their off value.** This is
|
||||||
/// toggle so that flipping it off in triple-c overrides any prior on-state
|
/// the `MANAGED_AUTH_KEYS` rule: `docker commit` bakes a container's env into
|
||||||
/// stored in the persisted settings.json (which lives in a named volume).
|
/// 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(
|
fn build_claude_code_settings_json(
|
||||||
settings: Option<&ClaudeCodeSettings>,
|
settings: Option<&ClaudeCodeSettings>,
|
||||||
sandbox_enabled: bool,
|
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();
|
let mut map = serde_json::Map::new();
|
||||||
|
|
||||||
if let Some(s) = settings {
|
// `null` clears; see the module doc above.
|
||||||
if let Some(ref tui) = s.tui_mode {
|
map.insert(
|
||||||
map.insert("tui".to_string(), serde_json::json!(tui));
|
"tui".to_string(),
|
||||||
}
|
match s.tui_mode {
|
||||||
if let Some(ref effort) = s.effort {
|
Some(ref tui) => serde_json::json!(tui),
|
||||||
map.insert("effort".to_string(), serde_json::json!(effort));
|
None => serde_json::Value::Null,
|
||||||
}
|
},
|
||||||
if s.auto_scroll_disabled {
|
);
|
||||||
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
|
// `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 {
|
if s.focus_mode {
|
||||||
map.insert("focusMode".to_string(), serde_json::json!(true));
|
serde_json::json!("focus")
|
||||||
}
|
} else {
|
||||||
if s.show_thinking_summaries {
|
serde_json::Value::Null
|
||||||
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
|
},
|
||||||
}
|
);
|
||||||
}
|
// 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
|
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
|
||||||
// off in triple-c clears any prior on-state in the persisted
|
// 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);
|
map.insert("sandbox".to_string(), sandbox_obj);
|
||||||
|
|
||||||
if map.is_empty() {
|
serde_json::Value::Object(map).to_string()
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(serde_json::Value::Object(map).to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, 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,
|
global_claude_code_settings,
|
||||||
project.claude_code_settings.as_ref(),
|
project.claude_code_settings.as_ref(),
|
||||||
);
|
);
|
||||||
if let Some(ref cc) = merged_cc_settings {
|
// Env-var-based settings, read directly by Claude Code. Extracted and unit
|
||||||
// Env-var-based settings (these are read directly by Claude Code)
|
// tested for the `vpn_host_config` reason: a container is created once, by
|
||||||
if cc.tui_mode.as_deref() == Some("fullscreen") {
|
// a very long function, and a variable emitted with the wrong value here is
|
||||||
env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string());
|
// invisible until someone wonders why a switch does nothing.
|
||||||
}
|
env_vars.extend(claude_code_env_vars(merged_cc_settings.as_ref()));
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settings.json-based settings (written by the entrypoint).
|
// settings.json-based settings (applied by the entrypoint). Always emitted,
|
||||||
// Always invoked so per-project sandbox state is injected even when no
|
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
|
||||||
// ClaudeCodeSettings struct is present.
|
// *whole* managed key set, so "no settings" still has to be stated over a
|
||||||
if let Some(settings_json) = build_claude_code_settings_json(
|
// settings.json left behind on the config volume by a previous config.
|
||||||
merged_cc_settings.as_ref(),
|
env_vars.push(format!(
|
||||||
project.sandbox_mode_enabled,
|
"CLAUDE_CODE_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));
|
));
|
||||||
}
|
|
||||||
|
|
||||||
let mut mounts: Vec<Mount> = Vec::new();
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
|
|
||||||
@@ -1571,6 +1701,14 @@ pub async fn create_container(
|
|||||||
// always meant to do.
|
// always meant to do.
|
||||||
labels.insert("triple-c.mcp-fingerprint".to_string(), String::new());
|
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 {
|
for (key, value) in extras.extra_labels {
|
||||||
labels.insert((*key).to_string(), (*value).to_string());
|
labels.insert((*key).to_string(), (*value).to_string());
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1719,7 @@ pub async fn create_container(
|
|||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||||
init: Some(true),
|
init: Some(true),
|
||||||
|
log_config: Some(capped_log_config()),
|
||||||
cap_add,
|
cap_add,
|
||||||
devices,
|
devices,
|
||||||
sysctls,
|
sysctls,
|
||||||
@@ -1618,6 +1757,36 @@ pub async fn create_container(
|
|||||||
Ok(response.id)
|
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> {
|
pub async fn start_container(container_id: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
docker
|
docker
|
||||||
@@ -1768,6 +1937,155 @@ chmod 600 "$HOME/.aws/credentials""#;
|
|||||||
Ok(())
|
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
|
/// Commit the container's filesystem to a snapshot image so that system-level
|
||||||
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
||||||
/// removal.
|
/// removal.
|
||||||
@@ -1795,10 +2113,21 @@ chmod 600 "$HOME/.aws/credentials""#;
|
|||||||
///
|
///
|
||||||
/// Non-secret env (PATH, TZ, model aliases, instructions) is inherited as
|
/// Non-secret env (PATH, TZ, model aliases, instructions) is inherited as
|
||||||
/// before, so nothing about the snapshot's behaviour changes.
|
/// 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> {
|
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let image_name = get_snapshot_image_name(project);
|
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
|
// Parse repo:tag
|
||||||
let (repo, tag) = match image_name.rsplit_once(':') {
|
let (repo, tag) = match image_name.rsplit_once(':') {
|
||||||
Some((r, t)) => (r.to_string(), t.to_string()),
|
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
|
/// `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.
|
/// migration's `pre-migration-*` pin is the only copy of a rollback target.
|
||||||
/// Neither can ever match this filter, so neither can be swept.
|
/// Neither can ever match this filter, so neither can be swept.
|
||||||
/// * **`triple-c.managed=true`** — only images Triple-C itself committed.
|
/// * **`triple-c.managed=true`** — only images Triple-C itself built or
|
||||||
/// `docker commit` copies the container's labels onto the image, which is what
|
/// committed. `docker commit` copies the container's labels onto the image,
|
||||||
/// makes the label a reliable mark of provenance. The user's own dangling
|
/// and `container/Dockerfile` stamps the same label on the base, which is
|
||||||
/// images are none of our business.
|
/// 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
|
/// 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
|
/// 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 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.
|
/// 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
|
/// Never fails the caller: this is housekeeping, and a full disk is a better
|
||||||
/// outcome than a project that will not start.
|
/// outcome than a project that will not start.
|
||||||
@@ -1969,6 +2313,38 @@ pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport {
|
|||||||
report
|
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
|
/// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user
|
||||||
/// what actually happened rather than guessing.
|
/// what actually happened rather than guessing.
|
||||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||||
@@ -3119,4 +3495,334 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(blind.left_something_behind());
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,13 +712,76 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
.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(
|
.remove_container(
|
||||||
&id,
|
id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
force: true,
|
force: true,
|
||||||
v: true,
|
v: true,
|
||||||
@@ -727,10 +790,57 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.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> {
|
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"))
|
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 4–12 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`.
|
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||||
match image.rsplit_once(':') {
|
match image.rsplit_once(':') {
|
||||||
@@ -1622,4 +1871,73 @@ mod tests {
|
|||||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Auto-start web terminal server if enabled in settings
|
||||||
let settings = settings_store_setup.get();
|
let settings = settings_store_setup.get();
|
||||||
if settings.web_terminal.enabled {
|
if settings.web_terminal.enabled {
|
||||||
|
|||||||
@@ -86,24 +86,42 @@ impl PermissionMode {
|
|||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
pub struct ClaudeCodeSettings {
|
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)]
|
#[serde(default)]
|
||||||
pub tui_mode: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
pub effort: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
pub auto_scroll_disabled: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub focus_mode: bool,
|
pub focus_mode: bool,
|
||||||
/// Show thinking summaries in responses
|
/// Show thinking summaries in responses
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub show_thinking_summaries: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub enable_session_recap: bool,
|
pub session_recap_disabled: bool,
|
||||||
/// Strip credentials from subprocess environments
|
/// Strip credentials from subprocess environments
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub env_scrub: bool,
|
pub env_scrub: bool,
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ fn sanitize(project_id: &str) -> String {
|
|||||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
/// 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
|
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||||
/// blocking every future migration on a corrupt record.
|
/// 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 4–12 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> {
|
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -59,16 +69,34 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
|||||||
match serde_json::from_str::<MigrationState>(&data) {
|
match serde_json::from_str::<MigrationState>(&data) {
|
||||||
Ok(state) => Ok(Some(state)),
|
Ok(state) => Ok(Some(state)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
let backup = path.with_extension("json.bak");
|
||||||
|
let moved = fs::rename(&path, &backup);
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
"Failed to parse migration state for project {}: {} — treating as absent{}",
|
||||||
project_id,
|
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)
|
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.
|
/// Atomically write a project's migration state.
|
||||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||||
let path = state_path(project_id)?;
|
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,
|
auto_scroll_disabled: false,
|
||||||
focus_mode: false,
|
focus_mode: false,
|
||||||
show_thinking_summaries: false,
|
show_thinking_summaries: false,
|
||||||
enable_session_recap: false,
|
session_recap_disabled: false,
|
||||||
env_scrub: false,
|
env_scrub: false,
|
||||||
prompt_caching_1h: false,
|
prompt_caching_1h: false,
|
||||||
};
|
};
|
||||||
@@ -28,16 +28,25 @@ function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
|||||||
s.auto_scroll_disabled === false &&
|
s.auto_scroll_disabled === false &&
|
||||||
s.focus_mode === false &&
|
s.focus_mode === false &&
|
||||||
s.show_thinking_summaries === false &&
|
s.show_thinking_summaries === false &&
|
||||||
s.enable_session_recap === false &&
|
s.session_recap_disabled === false &&
|
||||||
s.env_scrub === false &&
|
s.env_scrub === false &&
|
||||||
s.prompt_caching_1h === 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: {
|
const BOOLEAN_FIELDS: {
|
||||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
|
invert?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
{ 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.",
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enable_session_recap",
|
key: "session_recap_disabled",
|
||||||
label: "Session recap",
|
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",
|
key: "auto_scroll_disabled",
|
||||||
label: "Auto-scroll disabled",
|
label: "Auto-scroll",
|
||||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
hint: "Follows new output to the bottom in fullscreen rendering.",
|
||||||
|
invert: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "env_scrub",
|
key: "env_scrub",
|
||||||
@@ -95,9 +106,16 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
</p>
|
</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
|
<SwitchRow
|
||||||
label="TUI mode"
|
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={
|
control={
|
||||||
<select
|
<select
|
||||||
value={local.tui_mode ?? ""}
|
value={local.tui_mode ?? ""}
|
||||||
@@ -106,7 +124,8 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="">Default</option>
|
<option value="">Automatic</option>
|
||||||
|
<option value="default">Classic</option>
|
||||||
<option value="fullscreen">Fullscreen</option>
|
<option value="fullscreen">Fullscreen</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@@ -127,11 +146,12 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
|
<option value="xhigh">Extra high</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
key={key}
|
key={key}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -139,9 +159,11 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
control={
|
control={
|
||||||
<Toggle
|
<Toggle
|
||||||
label={label}
|
label={label}
|
||||||
checked={local[key]}
|
checked={invert ? !local[key] : local[key]}
|
||||||
disabled={disabled}
|
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)]">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
Are you sure you want to remove{" "}
|
Are you sure you want to remove{" "}
|
||||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||||
delete the container, config volume, and stored credentials.
|
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>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task && (
|
{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)]">
|
<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
|
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
|
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
|
||||||
the old id.
|
with it.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -134,12 +134,19 @@ export interface OpenAiCompatibleConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeCodeSettings {
|
export interface ClaudeCodeSettings {
|
||||||
|
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
|
||||||
tui_mode: string | null;
|
tui_mode: string | null;
|
||||||
|
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
|
||||||
effort: string | null;
|
effort: string | null;
|
||||||
auto_scroll_disabled: boolean;
|
auto_scroll_disabled: boolean;
|
||||||
|
/** Written as `viewMode: "focus"`. */
|
||||||
focus_mode: boolean;
|
focus_mode: boolean;
|
||||||
show_thinking_summaries: 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;
|
env_scrub: boolean;
|
||||||
prompt_caching_1h: boolean;
|
prompt_caching_1h: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
FROM ubuntu:24.04
|
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)
|
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
|
||||||
# Avoid interactive prompts during package install
|
# Avoid interactive prompts during package install
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|||||||
+27
-12
@@ -405,22 +405,37 @@ install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}"
|
|||||||
unset VPN_SUPPORT_ENABLED
|
unset VPN_SUPPORT_ENABLED
|
||||||
|
|
||||||
# ── Claude Code settings ────────────────────────────────────────────────────
|
# ── Claude Code settings ────────────────────────────────────────────────────
|
||||||
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
|
# Apply the managed Claude Code settings to ~/.claude/settings.json, keeping
|
||||||
# keys). Creates the file if it doesn't exist. These control TUI mode, effort
|
# every key the user set inside the container.
|
||||||
# level, focus mode, thinking summaries, and other CLI behavior.
|
#
|
||||||
|
# `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
|
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
||||||
SETTINGS_FILE="/home/claude/.claude/settings.json"
|
SETTINGS_FILE="/home/claude/.claude/settings.json"
|
||||||
mkdir -p /home/claude/.claude
|
mkdir -p /home/claude/.claude
|
||||||
if [ -f "$SETTINGS_FILE" ]; then
|
# One code path for "file exists" and "file doesn't": seeding an empty
|
||||||
# Merge: existing settings + new settings (new keys override on conflict)
|
# object means the null-deleting merge below runs in both cases, so a fresh
|
||||||
MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
|
# container never gets a settings.json with literal nulls written into it.
|
||||||
if [ -n "$MERGED" ]; then
|
[ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE"
|
||||||
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
|
MERGED=$(jq -s '
|
||||||
else
|
.[0] as $current
|
||||||
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
|
| .[1] as $managed
|
||||||
fi
|
| ($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
|
else
|
||||||
printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
|
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$SETTINGS_FILE"
|
chown claude:claude "$SETTINGS_FILE"
|
||||||
chmod 600 "$SETTINGS_FILE"
|
chmod 600 "$SETTINGS_FILE"
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ generate_id() {
|
|||||||
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
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
|
# 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.
|
# 0 when the task is genuinely running, returns 1 otherwise.
|
||||||
#
|
#
|
||||||
@@ -292,6 +313,7 @@ cmd_remove() {
|
|||||||
local name
|
local name
|
||||||
name=$(jq -r '.name' "$task_file")
|
name=$(jq -r '.name' "$task_file")
|
||||||
rm -f "$task_file"
|
rm -f "$task_file"
|
||||||
|
reap_task_logs "$id"
|
||||||
rebuild_crontab
|
rebuild_crontab
|
||||||
echo "Removed task '$name' ($id)"
|
echo "Removed task '$name' ($id)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,37 @@ fi
|
|||||||
echo "=== Exit code: $EXIT_CODE ==="
|
echo "=== Exit code: $EXIT_CODE ==="
|
||||||
} >> "$LOG_FILE"
|
} >> "$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 ──────────────────────────────────────────────────────
|
# ── Write notification ──────────────────────────────────────────────────────
|
||||||
mkdir -p "$NOTIFICATIONS_DIR"
|
mkdir -p "$NOTIFICATIONS_DIR"
|
||||||
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
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
|
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||||
fi
|
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) ─────────────────────────────────
|
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||||
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||||
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user