diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 9bdc011..3ccdfba 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -238,6 +238,7 @@ const RESERVED_ENV_EXACT: &[&str] = &[ "CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", + "CLAUDE_CODE_SETTINGS_CLEAR", "MISSION_CONTROL_ENABLED", "VPN_SUPPORT_ENABLED", "TRIPLE_C_PERMISSION_MODE", @@ -867,6 +868,48 @@ fn build_claude_code_settings_json( serde_json::Value::Object(map).to_string() } +/// Split the managed payload into the half that is safe to merge anywhere and +/// the list of keys to delete. +/// +/// The null-means-delete convention is only understood by the `entrypoint.sh` +/// shipped alongside this code — and an existing project recreates from *its +/// own snapshot image*, which carries whatever entrypoint it was built with. An +/// older one merges with a plain `.[0] * .[1]`, which would write the literal +/// `null`s straight into the user's `settings.json` rather than clearing the +/// keys. A settings file Claude Code then rejects would take the user's own +/// `model`, `statusLine` and everything else in it down with it. +/// +/// So the nulls never leave Rust. `CLAUDE_CODE_SETTINGS_JSON` carries only real +/// values and stays safe under either merge; `CLAUDE_CODE_SETTINGS_CLEAR` +/// carries the key names to delete and is simply ignored by an entrypoint that +/// predates it. Such a project keeps the old sticky behaviour until it is +/// migrated or Reset — which is the pre-existing state, not a regression. +pub(crate) fn split_claude_code_settings_payload(payload: &str) -> (String, String) { + let parsed: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + // Not our business to fix; hand it through and let the merge fail loudly. + Err(_) => return (payload.to_string(), "[]".to_string()), + }; + let Some(obj) = parsed.as_object() else { + return (payload.to_string(), "[]".to_string()); + }; + + let mut set = serde_json::Map::new(); + let mut clear: Vec = Vec::new(); + for (k, v) in obj { + if v.is_null() { + clear.push(serde_json::json!(k)); + } else { + set.insert(k.clone(), v.clone()); + } + } + + ( + serde_json::Value::Object(set).to_string(), + serde_json::Value::Array(clear).to_string(), + ) +} + pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -1471,10 +1514,14 @@ pub async fn create_container( // even with no `ClaudeCodeSettings` struct present: the payload asserts the // *whole* managed key set, so "no settings" still has to be stated over a // settings.json left behind on the config volume by a previous config. - env_vars.push(format!( - "CLAUDE_CODE_SETTINGS_JSON={}", - build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled) - )); + // Split so the payload is safe under an older entrypoint that has never + // heard of the null-means-delete convention — see + // `split_claude_code_settings_payload`. + let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload( + &build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled), + ); + env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set)); + env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear)); let mut mounts: Vec = Vec::new(); @@ -3628,6 +3675,73 @@ mod tests { "sandbox", ]; + #[test] + fn split_payload_never_emits_a_null_to_an_older_entrypoint() { + // An existing project recreates from its own snapshot, which carries + // whatever entrypoint it was built with. An older one merges with a + // plain `.[0] * .[1]`, so a null reaching it would be written into the + // user's settings.json verbatim instead of clearing the key. + let payload = build_claude_code_settings_json(None, false); + assert!( + payload.contains("null"), + "precondition: the unsplit payload uses null to mean delete" + ); + + let (set, clear) = split_claude_code_settings_payload(&payload); + + let set_val: serde_json::Value = serde_json::from_str(&set).unwrap(); + for (k, v) in set_val.as_object().unwrap() { + assert!(!v.is_null(), "key {} reached the merge half as a null", k); + } + + let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap(); + let cleared: Vec<&str> = clear_val + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + // The four whose neutral state is genuinely "unset". + for k in ["tui", "effortLevel", "viewMode", "awaySummaryEnabled"] { + assert!(cleared.contains(&k), "{} should be cleared, not pinned", k); + } + } + + #[test] + fn split_payload_keeps_every_key_exactly_once() { + // Nothing may be dropped or duplicated between the two halves, or a + // managed key would silently stop being asserted. + let settings = ClaudeCodeSettings { + tui_mode: Some("fullscreen".to_string()), + focus_mode: true, + ..Default::default() + }; + let payload = build_claude_code_settings_json(Some(&settings), true); + let original: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + let (set, clear) = split_claude_code_settings_payload(&payload); + let set_val: serde_json::Value = serde_json::from_str(&set).unwrap(); + let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap(); + + let mut seen: Vec = set_val + .as_object() + .unwrap() + .keys() + .cloned() + .chain( + clear_val + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()), + ) + .collect(); + seen.sort(); + let mut expected: Vec = original.as_object().unwrap().keys().cloned().collect(); + expected.sort(); + assert_eq!(seen, expected); + } + #[test] fn turning_a_setting_off_clears_it_rather_than_omitting_it() { // This is the whole bug. `~/.claude/settings.json` lives on a persisted diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 6dd22c7..f1c6249 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -412,25 +412,29 @@ unset VPN_SUPPORT_ENABLED # 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. +# setting stayed on until a destructive Reset. So Rust states the whole managed +# key set on every start, in two parts: CLAUDE_CODE_SETTINGS_JSON holds the keys +# that have a value, and CLAUDE_CODE_SETTINGS_CLEAR is a JSON array of the key +# names whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`, +# `awaySummaryEnabled`) and which must therefore be deleted rather than pinned +# to a stand-in value. +# +# The two are kept apart rather than using a null-means-delete payload because +# a project recreates from its own snapshot image, which carries whatever +# entrypoint it was built with. An older one merges with a plain `.[0] * .[1]` +# and would write literal nulls into the user's settings.json; it ignores +# CLAUDE_CODE_SETTINGS_CLEAR instead, keeping the old sticky behaviour until the +# project is migrated or Reset. # See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs. if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then SETTINGS_FILE="/home/claude/.claude/settings.json" mkdir -p /home/claude/.claude - # One code path for "file exists" and "file doesn't": seeding an empty - # object means the null-deleting merge below runs in both cases, so a fresh - # container never gets a settings.json with literal nulls written into it. + # One code path for "file exists" and "file doesn't". [ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE" - MERGED=$(jq -s ' + MERGED=$(jq -s --argjson clear "${CLAUDE_CODE_SETTINGS_CLEAR:-[]}" ' .[0] as $current - | .[1] as $managed - | ($managed | with_entries(select(.value != null))) as $set - | ($managed | to_entries | map(select(.value == null) | [.key])) as $clear - | ($current * $set) | delpaths($clear) + | .[1] as $set + | ($current * $set) | delpaths($clear | map([.])) ' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null) if [ -n "$MERGED" ]; then printf '%s\n' "$MERGED" > "$SETTINGS_FILE" @@ -440,6 +444,7 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then chown claude:claude "$SETTINGS_FILE" chmod 600 "$SETTINGS_FILE" unset CLAUDE_CODE_SETTINGS_JSON + unset CLAUDE_CODE_SETTINGS_CLEAR fi # ── AWS SSO auth refresh command ──────────────────────────────────────────────