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