Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30

Merged
jknapp merged 62 commits from ship/core into main 2026-08-25 18:02:58 +00:00
2 changed files with 136 additions and 17 deletions
Showing only changes of commit bb41275cea - Show all commits
+118 -4
View File
@@ -238,6 +238,7 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"CLAUDE_INSTRUCTIONS", "CLAUDE_INSTRUCTIONS",
"MCP_SERVERS_JSON", "MCP_SERVERS_JSON",
"CLAUDE_CODE_SETTINGS_JSON", "CLAUDE_CODE_SETTINGS_JSON",
"CLAUDE_CODE_SETTINGS_CLEAR",
"MISSION_CONTROL_ENABLED", "MISSION_CONTROL_ENABLED",
"VPN_SUPPORT_ENABLED", "VPN_SUPPORT_ENABLED",
"TRIPLE_C_PERMISSION_MODE", "TRIPLE_C_PERMISSION_MODE",
@@ -867,6 +868,48 @@ fn build_claude_code_settings_json(
serde_json::Value::Object(map).to_string() 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> { pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); 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 // even with no `ClaudeCodeSettings` struct present: the payload asserts the
// *whole* managed key set, so "no settings" still has to be stated over a // *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. // settings.json left behind on the config volume by a previous config.
env_vars.push(format!( // Split so the payload is safe under an older entrypoint that has never
"CLAUDE_CODE_SETTINGS_JSON={}", // heard of the null-means-delete convention — see
build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled) // `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(); let mut mounts: Vec<Mount> = Vec::new();
@@ -3628,6 +3675,73 @@ mod tests {
"sandbox", "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] #[test]
fn turning_a_setting_off_clears_it_rather_than_omitting_it() { fn turning_a_setting_off_clears_it_rather_than_omitting_it() {
// This is the whole bug. `~/.claude/settings.json` lives on a persisted // 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 # 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 # *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 # 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 # setting stayed on until a destructive Reset. So Rust states the whole managed
# the whole managed key set on every start, and a JSON **null** in it means # key set on every start, in two parts: CLAUDE_CODE_SETTINGS_JSON holds the keys
# "delete this key" rather than "merge a null" — which is how a setting whose # that have a value, and CLAUDE_CODE_SETTINGS_CLEAR is a JSON array of the key
# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`, # names whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
# `awaySummaryEnabled`) is turned back off without pinning a stand-in value. # `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. # 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
# One code path for "file exists" and "file doesn't": seeding an empty # One code path for "file exists" and "file doesn't".
# object means the null-deleting merge below runs in both cases, so a fresh
# container never gets a settings.json with literal nulls written into it.
[ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE" [ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE"
MERGED=$(jq -s ' MERGED=$(jq -s --argjson clear "${CLAUDE_CODE_SETTINGS_CLEAR:-[]}" '
.[0] as $current .[0] as $current
| .[1] as $managed | .[1] as $set
| ($managed | with_entries(select(.value != null))) as $set | ($current * $set) | delpaths($clear | map([.]))
| ($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) ' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
if [ -n "$MERGED" ]; then if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$SETTINGS_FILE" printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
@@ -440,6 +444,7 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
chown claude:claude "$SETTINGS_FILE" chown claude:claude "$SETTINGS_FILE"
chmod 600 "$SETTINGS_FILE" chmod 600 "$SETTINGS_FILE"
unset CLAUDE_CODE_SETTINGS_JSON unset CLAUDE_CODE_SETTINGS_JSON
unset CLAUDE_CODE_SETTINGS_CLEAR
fi fi
# ── AWS SSO auth refresh command ────────────────────────────────────────────── # ── AWS SSO auth refresh command ──────────────────────────────────────────────