Keep the managed settings payload safe for un-migrated projects

The null-means-delete convention is only understood by the entrypoint.sh
shipped alongside this code. An existing project recreates from its own
snapshot image, which carries whatever entrypoint it was built with, and
an older one merges with a plain `.[0] * .[1]` — so the literal nulls
would land in the user's settings.json rather than clearing the keys.
Verified against jq: that produces `"tui": null, "effortLevel": null,
"viewMode": null, "awaySummaryEnabled": null`, risking the whole file
being rejected and taking the user's own `model` and `statusLine` with it.

Split the payload instead. CLAUDE_CODE_SETTINGS_JSON now carries only
keys that have a value and is safe under either merge; the new
CLAUDE_CODE_SETTINGS_CLEAR carries the key names to delete and is
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 rather than a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:40:27 -07:00
co-authored by Claude Opus 5
parent 2ca86bb5d8
commit bb41275cea
2 changed files with 136 additions and 17 deletions
+118 -4
View File
@@ -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<serde_json::Value> = 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<Option<String>, 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<Mount> = 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<String> = 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<String> = 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
+18 -13
View File
@@ -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 ──────────────────────────────────────────────