From 52cb424119caa4342281eba31faf4ceb9e499ea5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:46:02 -0700 Subject: [PATCH 01/13] test(cmux-tui): the default shell prompt must survive rapid resizes after a partial line A Cloud terminal leaves prompt fragments on the prompt row after an aggressive resize. The shell cmux-tui launches emits no OSC 133 prompt marks, so ghostty-vt reflows a partial output line together with the prompt and each SIGWINCH redraw lands on the wrong cells. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/crates/cmux-tui-core/src/lib.rs | 1 + .../cmux-tui-core/src/shell_integration.rs | 13 ++ cmux-tui/crates/cmux-tui-core/src/surface.rs | 123 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 cmux-tui/crates/cmux-tui-core/src/shell_integration.rs diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 50ee85ed10ae..1ec8fd1fb6b0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod resource_name; mod resource_router; mod resource_selector; mod resource_tab; +mod shell_integration; mod short_id; mod sidebar_resource; pub mod sizing_policy; diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs new file mode 100644 index 000000000000..371a693e12e6 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -0,0 +1,13 @@ +//! Launch description for the interactive shells cmux-tui starts. + +/// An interactive shell launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellLaunch { + pub command: Vec, + pub env: Vec<(String, String)>, +} + +/// The default interactive shell, launched as given. +pub fn integrate_default_shell(command: Vec, extra_env: Vec<(String, String)>) -> ShellLaunch { + ShellLaunch { command, env: extra_env } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 7c2215f667e6..0e3a5658f335 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -8172,6 +8172,129 @@ mod tests { } } + /// A shell program on PATH that shell integration supports, if any. + #[cfg(unix)] + fn find_integrated_shell(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .filter(|candidate| { + // Apple's /bin/bash 3.2 cannot run the bash injection. + !(cfg!(target_os = "macos") && candidate == std::path::Path::new("/bin/bash")) + }) + .find(|candidate| candidate.is_file()) + .map(|candidate| candidate.to_string_lossy().into_owned()) + } + + #[cfg(unix)] + fn wait_for_viewport( + surface: &Surface, + what: &str, + mut ready: impl FnMut(&str, bool) -> bool, + ) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let (text, at_prompt) = surface + .try_with_terminal(|terminal| { + let text = terminal.viewport_text(); + (text, terminal.cursor_is_at_prompt()) + }) + .unwrap(); + let text = text.unwrap(); + if ready(&text, at_prompt) { + return text; + } + assert!(Instant::now() < deadline, "timed out waiting for {what}: {text:?}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// The default shell runs with Ghostty's shell integration, so its prompt + /// carries OSC 133 marks. Without them, a partial output line before the + /// prompt (zsh PROMPT_SP, or any output without a trailing newline) is + /// reflowed together with the prompt on every resize, and each SIGWINCH + /// redraw leaves fragments of the previous prompt behind. This is the + /// resize artifact seen in Cloud terminals. + #[cfg(unix)] + #[test] + fn default_shell_prompt_survives_rapid_resizes_after_a_partial_line() { + let mut ran = 0; + for (index, shell) in ["zsh", "bash"].into_iter().enumerate() { + let Some(program) = find_integrated_shell(shell) else { continue }; + let home = std::env::temp_dir().join(format!( + "cmux-tui-prompt-resize-{}-{shell}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join(".zshenv"), "setopt NO_GLOBAL_RCS\n").unwrap(); + std::fs::write(home.join(".zshrc"), "PS1='prompt> '\nsetopt PROMPT_CR PROMPT_SP\n").unwrap(); + std::fs::write(home.join(".bashrc"), "PS1='prompt> '\n").unwrap(); + let launch = crate::shell_integration::integrate_default_shell( + vec![program], + vec![ + ("HOME".into(), home.to_string_lossy().into_owned()), + ("ZDOTDIR".into(), home.to_string_lossy().into_owned()), + ("HISTFILE".into(), home.join("history").to_string_lossy().into_owned()), + ], + ); + let mux = Mux::new_for_test("prompt-resize", SurfaceOptions::default()); + let surface = Surface::spawn( + 160 + index as SurfaceId, + SurfaceOptions { + command: Some(launch.command), + extra_env: launch.env, + cols: 60, + rows: 20, + ..SurfaceOptions::default() + }, + Arc::downgrade(&mux), + ) + .unwrap(); + wait_for_viewport(&surface, "the first prompt", |text, _| text.contains("prompt>")); + // Output without a trailing newline, then unsubmitted input. + surface.write_bytes(b"printf ghtly\r").unwrap(); + wait_for_viewport(&surface, "the prompt after the partial line", |text, _| { + text.matches("prompt>").count() >= 2 + }); + surface.write_bytes(b"nightly").unwrap(); + wait_for_viewport(&surface, "typed input", |text, _| text.contains("prompt> nightly")); + for step in 0..40u16 { + let cols = if step % 2 == 0 { 60 - step } else { 30 + step }; + surface.resize(cols, 20).unwrap(); + std::thread::sleep(Duration::from_millis(5)); + } + surface.resize(60, 20).unwrap(); + std::thread::sleep(Duration::from_millis(500)); + let text = wait_for_viewport(&surface, "the settled prompt", |text, _| { + text.contains("prompt> nightly") + }); + let at_prompt = surface.try_with_terminal(|terminal| terminal.cursor_is_at_prompt()).unwrap(); + assert_eq!( + text.matches("nightly").count(), + 1, + "{shell}: resizing left prompt fragments behind: {text:?}" + ); + assert_eq!( + text.matches("prompt>").count(), + 2, + "{shell}: resizing duplicated the prompt: {text:?}" + ); + assert!( + text.lines().any(|line| line.starts_with("ghtly")), + "{shell}: resizing erased the partial output line: {text:?}" + ); + assert!(at_prompt, "{shell}: the terminal never saw an OSC 133 prompt mark: {text:?}"); + drop(surface); + let _ = std::fs::remove_dir_all(&home); + ran += 1; + } + assert!(ran > 0, "neither zsh nor bash is installed"); + } + /// The embedded ghostty-vt terminal always parses 24-bit SGR and the /// frontends forward RGB cells losslessly, so children must be able to /// rely on truecolor even when the session server itself was started from From f7f022e70b20756a99d44c49091e42abd68249f3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:51:41 -0700 Subject: [PATCH 02/13] fix(cmux-tui): launch the default shell with Ghostty shell integration cmux-tui launched shells without OSC 133 prompt marks, so ghostty-vt could not tell a prompt from output. Every resize reflowed the prompt as output and each SIGWINCH redraw landed on the wrong cells, which left prompt fragments in Cloud terminals and could erase a partial line. The default interactive shell now gets Ghostty's shell integration the way Ghostty injects it: zsh through ZDOTDIR, bash through --posix and ENV, fish through XDG_DATA_DIRS. The scripts are embedded from the same Ghostty submodule that builds ghostty-vt and written to a content-hash directory under the cmux-tui state root, re-checked on every launch. Explicit commands and /bin/bash on macOS are left alone, as in Ghostty. CMUX_TUI_SHELL_INTEGRATION=none opts out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/shell_integration.rs | 330 +++++++++++++++++- cmux-tui/crates/cmux-tui-core/src/surface.rs | 18 +- .../src/terminal_host_runtime.rs | 18 +- 3 files changed, 350 insertions(+), 16 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs index 371a693e12e6..d27391952a16 100644 --- a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -1,13 +1,335 @@ -//! Launch description for the interactive shells cmux-tui starts. +//! Automatic shell integration for the interactive shells cmux-tui launches. +//! +//! The embedded terminal is ghostty-vt, which reflows the primary screen on +//! every resize and clears a redrawable prompt so the shell can repaint it. +//! Both depend on OSC 133 semantic prompt marks. Without them the terminal +//! cannot tell a prompt from output, so each SIGWINCH redraw lands on reflowed +//! cells and leaves prompt fragments behind. Ghostty emits those marks by +//! injecting its shell integration scripts when it spawns a shell; this module +//! does the same for cmux-tui, using the scripts from the Ghostty submodule +//! that builds ghostty-vt so both halves always match. +//! +//! The injection mirrors Ghostty's `src/termio/shell_integration.zig`: zsh via +//! `ZDOTDIR`, bash via `--posix` plus `ENV`, and fish via `XDG_DATA_DIRS`. -/// An interactive shell launch. +use std::ffi::OsStr; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +struct Script { + path: &'static str, + contents: &'static str, +} + +const SCRIPTS: &[Script] = &[ + Script { + path: "zsh/.zshenv", + contents: include_str!("../../../../ghostty/src/shell-integration/zsh/.zshenv"), + }, + Script { + path: "zsh/ghostty-integration", + contents: include_str!("../../../../ghostty/src/shell-integration/zsh/ghostty-integration"), + }, + Script { + path: "bash/ghostty.bash", + contents: include_str!("../../../../ghostty/src/shell-integration/bash/ghostty.bash"), + }, + Script { + path: "bash/bash-preexec.sh", + contents: include_str!("../../../../ghostty/src/shell-integration/bash/bash-preexec.sh"), + }, + Script { + path: "fish/vendor_conf.d/ghostty-shell-integration.fish", + contents: include_str!( + "../../../../ghostty/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish" + ), + }, +]; + +/// Opt-out: `CMUX_TUI_SHELL_INTEGRATION=none` launches shells unmodified. +const OPT_OUT_ENV: &str = "CMUX_TUI_SHELL_INTEGRATION"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Shell { + Bash, + Fish, + Zsh, +} + +/// An interactive shell launch after shell integration was applied. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShellLaunch { pub command: Vec, + /// Environment for the child, in application order. The input entries + /// come first, so an injected value wins over an inherited or extra one. pub env: Vec<(String, String)>, } -/// The default interactive shell, launched as given. +/// Apply shell integration to the default interactive shell. +/// +/// Only the default shell is modified: an explicit command is the caller's +/// program, and `-c` style launches are never interactive. Any failure leaves +/// the launch unchanged, so a shell always starts. pub fn integrate_default_shell(command: Vec, extra_env: Vec<(String, String)>) -> ShellLaunch { - ShellLaunch { command, env: extra_env } + let inherited = extra_env.clone(); + let lookup = move |key: &str| -> Option { + inherited + .iter() + .rev() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + .or_else(|| std::env::var(key).ok()) + }; + if lookup(OPT_OUT_ENV).as_deref() == Some("none") { + return ShellLaunch { command, env: extra_env }; + } + let Some(shell) = detect_shell(&command) else { + return ShellLaunch { command, env: extra_env }; + }; + let Some(root) = scripts_root().and_then(|root| materialize(&root).ok()) else { + return ShellLaunch { command, env: extra_env }; + }; + apply(shell, &root, command, extra_env, &lookup) +} + +fn detect_shell(command: &[String]) -> Option { + let exe = command.first()?; + let name = Path::new(exe).file_name().and_then(OsStr::to_str)?; + match name { + // Apple's patched Bash 3.2 ignores ENV in POSIX startup, which the + // bash injection depends on. /bin is SIP-protected, so /bin/bash on + // macOS is always that build. + "bash" if cfg!(target_os = "macos") && exe == "/bin/bash" => None, + "bash" => Some(Shell::Bash), + "fish" => Some(Shell::Fish), + "zsh" => Some(Shell::Zsh), + _ => None, + } +} + +fn apply( + shell: Shell, + root: &Path, + mut command: Vec, + mut env: Vec<(String, String)>, + lookup: &dyn Fn(&str) -> Option, +) -> ShellLaunch { + let root_str = root.to_string_lossy().into_owned(); + match shell { + Shell::Zsh => { + if let Some(previous) = lookup("ZDOTDIR") { + env.push(("GHOSTTY_ZSH_ZDOTDIR".into(), previous)); + } + env.push(("ZDOTDIR".into(), format!("{root_str}/zsh"))); + } + Shell::Bash => { + // The default shell carries no arguments of its own; anything + // that makes bash non-interactive or already POSIX is left alone. + if command.iter().skip(1).any(|arg| { + arg == "--posix" || (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('c')) + }) { + return ShellLaunch { command, env }; + } + command.insert(1, "--posix".into()); + if let Some(previous) = lookup("ENV") { + env.push(("GHOSTTY_BASH_ENV".into(), previous)); + } + env.push(("ENV".into(), format!("{root_str}/bash/ghostty.bash"))); + env.push(("GHOSTTY_BASH_INJECT".into(), "1".into())); + // POSIX mode defaults HISTFILE to ~/.sh_history; the script + // unexports this again once it leaves POSIX mode. + if lookup("HISTFILE").is_none() + && let Some(home) = lookup("HOME") + { + env.push(("HISTFILE".into(), format!("{home}/.bash_history"))); + env.push(("GHOSTTY_BASH_UNEXPORT_HISTFILE".into(), "1".into())); + } + } + Shell::Fish => { + let current = lookup("XDG_DATA_DIRS") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + env.push(("GHOSTTY_SHELL_INTEGRATION_XDG_DIR".into(), root_str.clone())); + env.push(("XDG_DATA_DIRS".into(), format!("{root_str}:{current}"))); + } + } + ShellLaunch { command, env } +} + +fn scripts_root() -> Option { + let base = crate::platform::workspace_state_dir() + .and_then(|sessions| sessions.parent().map(Path::to_path_buf)) + .unwrap_or_else(crate::platform::runtime_dir); + Some(base.join("shell-integration").join(content_digest())) +} + +fn content_digest() -> String { + let mut hasher = Sha256::new(); + for script in SCRIPTS { + hasher.update(script.path.as_bytes()); + hasher.update([0]); + hasher.update(script.contents.as_bytes()); + hasher.update([0]); + } + let digest = hasher.finalize(); + digest[..8].iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Write the scripts under `root`, or confirm they are already intact. The +/// check runs on every launch because a shell pointed at a missing script +/// would start without its rc files (bash stays in POSIX mode). +fn materialize(root: &Path) -> io::Result { + for script in SCRIPTS { + let path = root.join(script.path); + if fs::read(&path).is_ok_and(|existing| existing == script.contents.as_bytes()) { + continue; + } + let parent = path.parent().ok_or_else(|| io::Error::other("script has no parent"))?; + fs::create_dir_all(parent)?; + let mut dir = parent; + while dir.starts_with(root) { + crate::platform::restrict_directory(dir)?; + match dir.parent() { + Some(next) => dir = next, + None => break, + } + } + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().and_then(OsStr::to_str).unwrap_or("script"), + std::process::id() + )); + { + let mut file = fs::File::create(&temp)?; + file.write_all(script.contents.as_bytes())?; + file.sync_all()?; + } + crate::platform::restrict_file(&temp)?; + fs::rename(&temp, &path)?; + } + Ok(root.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env_of(launch: &ShellLaunch, key: &str) -> Option { + launch.env.iter().rev().find(|(name, _)| name == key).map(|(_, value)| value.clone()) + } + + fn launch(shell: &str, env: &[(&str, &str)]) -> ShellLaunch { + let env: Vec<(String, String)> = + env.iter().map(|(key, value)| ((*key).into(), (*value).into())).collect(); + let lookup = { + let env = env.clone(); + move |key: &str| env.iter().rev().find(|(name, _)| name == key).map(|(_, v)| v.clone()) + }; + apply( + detect_shell(&[shell.into()]).expect("supported shell"), + Path::new("/state/shell-integration/abc"), + vec![shell.into()], + env, + &lookup, + ) + } + + #[test] + fn detects_supported_shells_by_basename() { + assert_eq!(detect_shell(&["/usr/bin/zsh".into()]), Some(Shell::Zsh)); + assert_eq!(detect_shell(&["/opt/homebrew/bin/bash".into()]), Some(Shell::Bash)); + assert_eq!(detect_shell(&["fish".into()]), Some(Shell::Fish)); + assert_eq!(detect_shell(&["/bin/sh".into()]), None); + assert_eq!(detect_shell(&[]), None); + if cfg!(target_os = "macos") { + assert_eq!(detect_shell(&["/bin/bash".into()]), None); + } else { + assert_eq!(detect_shell(&["/bin/bash".into()]), Some(Shell::Bash)); + } + } + + #[test] + fn zsh_points_zdotdir_at_the_scripts_and_keeps_the_previous_one() { + let plain = launch("zsh", &[]); + assert_eq!(plain.command, vec!["zsh"]); + assert_eq!(env_of(&plain, "ZDOTDIR").as_deref(), Some("/state/shell-integration/abc/zsh")); + assert_eq!(env_of(&plain, "GHOSTTY_ZSH_ZDOTDIR"), None); + + let custom = launch("zsh", &[("ZDOTDIR", "/home/me/.config/zsh")]); + assert_eq!(env_of(&custom, "GHOSTTY_ZSH_ZDOTDIR").as_deref(), Some("/home/me/.config/zsh")); + assert_eq!(env_of(&custom, "ZDOTDIR").as_deref(), Some("/state/shell-integration/abc/zsh")); + } + + #[test] + fn bash_starts_in_posix_mode_with_env_pointing_at_the_script() { + let bash = launch("/usr/local/bin/bash", &[("HOME", "/home/me")]); + assert_eq!(bash.command, vec!["/usr/local/bin/bash", "--posix"]); + assert_eq!( + env_of(&bash, "ENV").as_deref(), + Some("/state/shell-integration/abc/bash/ghostty.bash") + ); + assert_eq!(env_of(&bash, "GHOSTTY_BASH_INJECT").as_deref(), Some("1")); + assert_eq!(env_of(&bash, "HISTFILE").as_deref(), Some("/home/me/.bash_history")); + assert_eq!(env_of(&bash, "GHOSTTY_BASH_UNEXPORT_HISTFILE").as_deref(), Some("1")); + + let with_env = launch("bash", &[("ENV", "/etc/env.sh"), ("HISTFILE", "/tmp/h")]); + assert_eq!(env_of(&with_env, "GHOSTTY_BASH_ENV").as_deref(), Some("/etc/env.sh")); + assert_eq!(env_of(&with_env, "HISTFILE").as_deref(), Some("/tmp/h")); + assert_eq!(env_of(&with_env, "GHOSTTY_BASH_UNEXPORT_HISTFILE"), None); + } + + #[test] + fn fish_prepends_the_scripts_to_xdg_data_dirs() { + let default = launch("fish", &[]); + assert_eq!( + env_of(&default, "XDG_DATA_DIRS").as_deref(), + Some("/state/shell-integration/abc:/usr/local/share:/usr/share") + ); + assert_eq!( + env_of(&default, "GHOSTTY_SHELL_INTEGRATION_XDG_DIR").as_deref(), + Some("/state/shell-integration/abc") + ); + let custom = launch("fish", &[("XDG_DATA_DIRS", "/opt/share")]); + assert_eq!( + env_of(&custom, "XDG_DATA_DIRS").as_deref(), + Some("/state/shell-integration/abc:/opt/share") + ); + } + + #[test] + fn materialize_writes_every_script_privately_and_repairs_missing_ones() { + let base = std::env::temp_dir().join(format!( + "cmux-tui-shell-integration-test-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let root = base.join("shell-integration").join(content_digest()); + materialize(&root).unwrap(); + for script in SCRIPTS { + assert_eq!(fs::read_to_string(root.join(script.path)).unwrap(), script.contents); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(root.join("zsh")).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } + fs::remove_file(root.join("bash/ghostty.bash")).unwrap(); + materialize(&root).unwrap(); + assert!(root.join("bash/ghostty.bash").is_file()); + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn opt_out_leaves_the_launch_unchanged() { + let launch = integrate_default_shell( + vec!["zsh".into()], + vec![(OPT_OUT_ENV.into(), "none".into())], + ); + assert_eq!(launch.command, vec!["zsh"]); + assert_eq!(launch.env, vec![(OPT_OUT_ENV.to_string(), "none".to_string())]); + } } diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 0e3a5658f335..d16407948b87 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -2394,11 +2394,17 @@ impl Surface { }; let pty = cmux_pty::open(initial_geometry.pty_size()?)?; - let argv = opts - .command - .clone() - .filter(|argv| !argv.is_empty()) - .unwrap_or_else(|| vec![platform::default_shell()]); + let launch = match opts.command.clone().filter(|argv| !argv.is_empty()) { + Some(argv) => crate::shell_integration::ShellLaunch { + command: argv, + env: opts.extra_env.clone(), + }, + None => crate::shell_integration::integrate_default_shell( + vec![platform::default_shell()], + opts.extra_env.clone(), + ), + }; + let argv = launch.command; let mut cmd = PtyCommand::new(&argv[0]); cmd.args(argv[1..].iter().cloned()); cmd.env("TERM", &opts.term); @@ -2408,7 +2414,7 @@ impl Surface { // (launchd, ssh, cron strip COLORTERM). Set before extra_env so a // caller can still override it. cmd.env("COLORTERM", "truecolor"); - for (k, v) in &opts.extra_env { + for (k, v) in &launch.env { cmd.env(k, v); } let cwd = opts.cwd.clone().or_else(platform::default_terminal_cwd); diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs index 49d147fbcc38..fb0231964159 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs @@ -2095,11 +2095,17 @@ mod unix { if record_path.exists() || endpoint.exists() { anyhow::bail!("terminal host identity already exists"); } - let command = options - .command - .clone() - .filter(|command| !command.is_empty()) - .unwrap_or_else(|| vec![crate::platform::default_shell()]); + let shell_launch = match options.command.clone().filter(|command| !command.is_empty()) { + Some(command) => crate::shell_integration::ShellLaunch { + command, + env: options.extra_env.clone(), + }, + None => crate::shell_integration::integrate_default_shell( + vec![crate::platform::default_shell()], + options.extra_env.clone(), + ), + }; + let command = shell_launch.command; let launch = HostLaunch { endpoint: endpoint.to_string_lossy().into_owned(), record_path: record_path.to_string_lossy().into_owned(), @@ -2110,7 +2116,7 @@ mod unix { scrollback: options.scrollback, cwd: options.cwd.clone().or_else(crate::platform::default_terminal_cwd), command, - extra_env: options.extra_env.clone(), + extra_env: shell_launch.env, default_colors, kitty_graphics_limits, }; From cddce8dda164c619b982785e51ff327a66b5f3a2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:02:49 -0700 Subject: [PATCH 03/13] Bump Ghostty to start a 133;P primary prompt on its own line Co-Authored-By: Claude Opus 5.5 (1M context) --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index e1b8bf5f478c..1975783f425f 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit e1b8bf5f478c6aadbf70e51cdbb41930e92fda10 +Subproject commit 1975783f425f6a28c92f0fe8216846a03f5a1427 From 89e112d49a6e4e97d81f3d6b3ff2bab2dbdd696a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:06:03 -0700 Subject: [PATCH 04/13] Bump Ghostty to drop wrap padding before a prompt Co-Authored-By: Claude Opus 5.5 (1M context) --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 1975783f425f..33620abfb1c1 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 1975783f425f6a28c92f0fe8216846a03f5a1427 +Subproject commit 33620abfb1c1ac410835471946abac7b55c3bdd5 From b3721b08a80f3b64f4b2d4fc0c4e61cb70c29b35 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:09:36 -0700 Subject: [PATCH 05/13] Bump Ghostty for the corrected prompt padding test Co-Authored-By: Claude Opus 5.5 (1M context) --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 33620abfb1c1..c318e7825376 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 33620abfb1c1ac410835471946abac7b55c3bdd5 +Subproject commit c318e78253762d8c6eb7e117d5929b79a562a444 From a70828e27bd86a3a991323e5b451a06b15baf64d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:30:33 -0700 Subject: [PATCH 06/13] style(cmux-tui): rustfmt the shell integration launch Co-Authored-By: Claude Opus 5.5 (1M context) --- .../crates/cmux-tui-core/src/shell_integration.rs | 14 ++++++++------ cmux-tui/crates/cmux-tui-core/src/surface.rs | 13 +++++++------ .../cmux-tui-core/src/terminal_host_runtime.rs | 7 +++---- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs index d27391952a16..2ae4f199bf15 100644 --- a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -73,7 +73,10 @@ pub struct ShellLaunch { /// Only the default shell is modified: an explicit command is the caller's /// program, and `-c` style launches are never interactive. Any failure leaves /// the launch unchanged, so a shell always starts. -pub fn integrate_default_shell(command: Vec, extra_env: Vec<(String, String)>) -> ShellLaunch { +pub fn integrate_default_shell( + command: Vec, + extra_env: Vec<(String, String)>, +) -> ShellLaunch { let inherited = extra_env.clone(); let lookup = move |key: &str| -> Option { inherited @@ -129,7 +132,8 @@ fn apply( // The default shell carries no arguments of its own; anything // that makes bash non-interactive or already POSIX is left alone. if command.iter().skip(1).any(|arg| { - arg == "--posix" || (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('c')) + arg == "--posix" + || (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('c')) }) { return ShellLaunch { command, env }; } @@ -325,10 +329,8 @@ mod tests { #[test] fn opt_out_leaves_the_launch_unchanged() { - let launch = integrate_default_shell( - vec!["zsh".into()], - vec![(OPT_OUT_ENV.into(), "none".into())], - ); + let launch = + integrate_default_shell(vec!["zsh".into()], vec![(OPT_OUT_ENV.into(), "none".into())]); assert_eq!(launch.command, vec!["zsh"]); assert_eq!(launch.env, vec![(OPT_OUT_ENV.to_string(), "none".to_string())]); } diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index d16407948b87..1115ac42bc7d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -2395,10 +2395,9 @@ impl Surface { let pty = cmux_pty::open(initial_geometry.pty_size()?)?; let launch = match opts.command.clone().filter(|argv| !argv.is_empty()) { - Some(argv) => crate::shell_integration::ShellLaunch { - command: argv, - env: opts.extra_env.clone(), - }, + Some(argv) => { + crate::shell_integration::ShellLaunch { command: argv, env: opts.extra_env.clone() } + } None => crate::shell_integration::integrate_default_shell( vec![platform::default_shell()], opts.extra_env.clone(), @@ -8237,7 +8236,8 @@ mod tests { )); std::fs::create_dir_all(&home).unwrap(); std::fs::write(home.join(".zshenv"), "setopt NO_GLOBAL_RCS\n").unwrap(); - std::fs::write(home.join(".zshrc"), "PS1='prompt> '\nsetopt PROMPT_CR PROMPT_SP\n").unwrap(); + std::fs::write(home.join(".zshrc"), "PS1='prompt> '\nsetopt PROMPT_CR PROMPT_SP\n") + .unwrap(); std::fs::write(home.join(".bashrc"), "PS1='prompt> '\n").unwrap(); let launch = crate::shell_integration::integrate_default_shell( vec![program], @@ -8278,7 +8278,8 @@ mod tests { let text = wait_for_viewport(&surface, "the settled prompt", |text, _| { text.contains("prompt> nightly") }); - let at_prompt = surface.try_with_terminal(|terminal| terminal.cursor_is_at_prompt()).unwrap(); + let at_prompt = + surface.try_with_terminal(|terminal| terminal.cursor_is_at_prompt()).unwrap(); assert_eq!( text.matches("nightly").count(), 1, diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs index fb0231964159..496ec130ebaa 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs @@ -2096,10 +2096,9 @@ mod unix { anyhow::bail!("terminal host identity already exists"); } let shell_launch = match options.command.clone().filter(|command| !command.is_empty()) { - Some(command) => crate::shell_integration::ShellLaunch { - command, - env: options.extra_env.clone(), - }, + Some(command) => { + crate::shell_integration::ShellLaunch { command, env: options.extra_env.clone() } + } None => crate::shell_integration::integrate_default_shell( vec![crate::platform::default_shell()], options.extra_env.clone(), From 859600a0402ba35ed9215c19458679322624d113 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:03:46 -0700 Subject: [PATCH 07/13] Pin the GhosttyKit archive for Ghostty c318e7825 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/ghostty-fork.md | 28 +++++++++++++++++++++++++++- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index fc8fe2cfd1e9..d9cd2eabf3b2 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -125,7 +125,12 @@ When we change the fork, update this document and the parent submodule SHA. - SHA-256 `98697b9a49b36e835e900f716ac054cf2476d97bf40ea2742454e735ac5aa3a9` is pinned in `scripts/ghosttykit-checksums.txt`. -The submodule pinned by this branch is `e1b8bf5f4`, the OSC 133;A prompt +The submodule pinned by this branch is `c318e7825`, the 133;P prompt and +wrap padding fix (section 16, manaflow-ai/ghostty#247) on top of +`e1b8bf5f4`. Artifact +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-c318e78253762d8c6eb7e117d5929b79a562a444-crashsubdir-cmux-crash-sentry-off-noi18n-v2 +has SHA-256 `3c97a9cf83e4d052e3fa589542210e96f61994b51589e11c1c527f74239d1540`, pinned in +`scripts/ghosttykit-checksums.txt`. The previous pin was `e1b8bf5f4`, the OSC 133;A prompt line fix (section 15, manaflow-ai/ghostty#245) on top of `9d8d40319`, which corrects the styled blank row test. Artifact https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-e1b8bf5f478c6aadbf70e51cdbb41930e92fda10-crashsubdir-cmux-crash-sentry-off-noi18n-v2 @@ -2048,6 +2053,27 @@ tend to conflict together during rebases. semantic-prompt reflow should keep a prompt at column 0 of its own logical line. +### 16) Primary 133;P prompts and wrap padding + +- Commits: + - `f1906ae5a` (test: a 133;P primary prompt after a padded partial line must stay on its own line) + - `1975783f4` (terminal: start a 133;P primary prompt on its own logical line) + - `33620abfb` (terminal: drop the padding that forced a wrap before a prompt) + - `c318e7825` (test: narrow to a width that still fits the cursor) +- Files: + - `src/terminal/Terminal.zig` + - `src/terminal/Screen.zig` +- Summary: + - Ghostty's bash integration marks a ble.sh prompt with `133;P;k=i`, not + `133;A`. An explicit primary prompt start at column 0 of a soft-wrap + continuation row now breaks that wrap too. + - Breaking the wrap also clears the trailing unstyled spaces on the row + above. They were padding that forced the wrap, and as text they reflowed + into blank rows where a shell redraw could land. +- Conflict notes: + - Keep `cursorBreakWrapIntoRow` limited to primary prompts: continuation + and right prompts must keep their wrap. + The current cmux pin is the merged head `34cbf180d`, which merges the surface registry serialization (`e5c962a72`, section 14, landed on cmux `main` via branch `issue-5458-surface-registry-lock`) into the Cmd-click link fix line diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b040009b10de..c67e65b5dd6b 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -138,3 +138,4 @@ e168fd31c0fc5893cdac933dc665307b3a760554 66d0089dcb7ea8873d86553e684e9b746d39c33 6dae1978effbd9812de1c93ea7a938dbde12532d dc9b024dc8ad913ced9a80b1c8e5e8532536d98389cdececcdc8aee233541364 9961d09be3faf962b6e50541c3b709d5cd234472 f3d611f151e7337b116cd15e653d734bc04dd33d8bd5af5fd065bebb6ad78be9 e1b8bf5f478c6aadbf70e51cdbb41930e92fda10 d18c7ddcc9f503cf2b03dff07b7001f4fc04f60d3d22bf84b2b4d5d5ce9ec885 +c318e78253762d8c6eb7e117d5929b79a562a444 3c97a9cf83e4d052e3fa589542210e96f61994b51589e11c1c527f74239d1540 From 7014f0cf287b939d10fc121809420a8e73015f81 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:19:02 -0700 Subject: [PATCH 08/13] fix(cmux-tui): write shell integration scripts only to private, owned paths Review follow-up. Every new shell sources these scripts, so each script directory and file must belong to this user and must not be a symlink, or the launch goes ahead without integration. Temp files get a per-call name, since shells can launch concurrently in one process. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/shell_integration.rs | 102 +++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs index 2ae4f199bf15..8735b2cd350a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -16,6 +16,7 @@ use std::ffi::OsStr; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use sha2::{Digest, Sha256}; @@ -185,38 +186,96 @@ fn content_digest() -> String { /// Write the scripts under `root`, or confirm they are already intact. The /// check runs on every launch because a shell pointed at a missing script /// would start without its rc files (bash stays in POSIX mode). +/// +/// Every new shell sources these files, so the directories and files must +/// belong to this user and must not be symlinks; otherwise the launch goes +/// ahead without integration. fn materialize(root: &Path) -> io::Result { + let container = root.parent().ok_or_else(|| io::Error::other("scripts root has no parent"))?; + if let Some(base) = container.parent() { + fs::create_dir_all(base)?; + } + ensure_private_dir(container)?; + ensure_private_dir(root)?; for script in SCRIPTS { let path = root.join(script.path); - if fs::read(&path).is_ok_and(|existing| existing == script.contents.as_bytes()) { - continue; - } let parent = path.parent().ok_or_else(|| io::Error::other("script has no parent"))?; - fs::create_dir_all(parent)?; + let mut missing = Vec::new(); let mut dir = parent; - while dir.starts_with(root) { - crate::platform::restrict_directory(dir)?; - match dir.parent() { - Some(next) => dir = next, - None => break, - } + while dir != root { + missing.push(dir); + dir = dir.parent().ok_or_else(|| io::Error::other("script outside its root"))?; + } + for dir in missing.into_iter().rev() { + ensure_private_dir(dir)?; } + if check_owned(&path, false).is_ok() + && fs::read(&path).is_ok_and(|existing| existing == script.contents.as_bytes()) + { + continue; + } + // Unique per call: shells can launch concurrently in one process. let temp = parent.join(format!( - ".{}.{}.tmp", + ".{}.{}.{}.tmp", path.file_name().and_then(OsStr::to_str).unwrap_or("script"), - std::process::id() + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) )); - { - let mut file = fs::File::create(&temp)?; + let written = (|| { + let mut file = fs::OpenOptions::new().write(true).create_new(true).open(&temp)?; file.write_all(script.contents.as_bytes())?; file.sync_all()?; + crate::platform::restrict_file(&temp)?; + fs::rename(&temp, &path) + })(); + if written.is_err() { + let _ = fs::remove_file(&temp); } - crate::platform::restrict_file(&temp)?; - fs::rename(&temp, &path)?; + written?; } Ok(root.to_path_buf()) } +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn ensure_private_dir(dir: &Path) -> io::Result<()> { + match fs::create_dir(dir) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + check_owned(dir, true)?; + crate::platform::restrict_directory(dir) +} + +#[cfg(unix)] +fn check_owned(path: &Path, directory: bool) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() + || metadata.is_dir() != directory + || metadata.uid() != crate::platform::effective_uid() + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} is not a private shell integration path", path.display()), + )); + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_owned(path: &Path, directory: bool) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || metadata.is_dir() != directory { + return Err(io::Error::other(format!( + "{} is not a shell integration path", + path.display() + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -324,6 +383,17 @@ mod tests { fs::remove_file(root.join("bash/ghostty.bash")).unwrap(); materialize(&root).unwrap(); assert!(root.join("bash/ghostty.bash").is_file()); + // A symlink in place of a script is replaced by the real file. + #[cfg(unix)] + { + let decoy = base.join("decoy"); + fs::write(&decoy, "echo hijacked\n").unwrap(); + fs::remove_file(root.join("zsh/.zshenv")).unwrap(); + std::os::unix::fs::symlink(&decoy, root.join("zsh/.zshenv")).unwrap(); + materialize(&root).unwrap(); + assert!(!fs::symlink_metadata(root.join("zsh/.zshenv")).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_to_string(&decoy).unwrap(), "echo hijacked\n"); + } fs::remove_dir_all(&base).unwrap(); } From f9e681b6ee4b4980c2577c6cab4c9b89f4d3089f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:20:03 -0700 Subject: [PATCH 09/13] style(cmux-tui): rustfmt the private script checks Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/crates/cmux-tui-core/src/shell_integration.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs index 8735b2cd350a..e143f9fd6567 100644 --- a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -391,7 +391,9 @@ mod tests { fs::remove_file(root.join("zsh/.zshenv")).unwrap(); std::os::unix::fs::symlink(&decoy, root.join("zsh/.zshenv")).unwrap(); materialize(&root).unwrap(); - assert!(!fs::symlink_metadata(root.join("zsh/.zshenv")).unwrap().file_type().is_symlink()); + assert!( + !fs::symlink_metadata(root.join("zsh/.zshenv")).unwrap().file_type().is_symlink() + ); assert_eq!(fs::read_to_string(&decoy).unwrap(), "echo hijacked\n"); } fs::remove_dir_all(&base).unwrap(); From d4e791ffa04c4a089b6f6fc7cda5bf91fa1b6561 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:18:55 -0700 Subject: [PATCH 10/13] fix(cmux-tui): refuse script roots below directories others can replace Review follow-up. Validate every ancestor of the canonical state root: each must belong to this user or root, and one that everyone can write to must be a root-owned sticky directory. Shells get the canonical path. The resize test now waits for the post-resize screen to settle, and the fork notes no longer call an old Ghostty commit the current pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/shell_integration.rs | 74 +++++++++++++++++-- cmux-tui/crates/cmux-tui-core/src/surface.rs | 14 +++- docs/ghostty-fork.md | 2 +- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs index e143f9fd6567..7a77882876f0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs +++ b/cmux-tui/crates/cmux-tui-core/src/shell_integration.rs @@ -188,14 +188,23 @@ fn content_digest() -> String { /// would start without its rc files (bash stays in POSIX mode). /// /// Every new shell sources these files, so the directories and files must -/// belong to this user and must not be symlinks; otherwise the launch goes -/// ahead without integration. +/// belong to this user and must not be symlinks, and no other user may be +/// able to replace a directory above them; otherwise the launch goes ahead +/// without integration. The returned root is canonical, so shells never +/// resolve the scripts through a symlink. fn materialize(root: &Path) -> io::Result { - let container = root.parent().ok_or_else(|| io::Error::other("scripts root has no parent"))?; - if let Some(base) = container.parent() { - fs::create_dir_all(base)?; - } - ensure_private_dir(container)?; + let invalid = || io::Error::other("scripts root needs a parent and a name"); + let digest = root.file_name().ok_or_else(invalid)?; + let container = root.parent().ok_or_else(invalid)?; + let container_name = container.file_name().ok_or_else(invalid)?; + let base = container.parent().ok_or_else(invalid)?; + fs::create_dir_all(base)?; + let base = fs::canonicalize(base)?; + check_trusted_ancestors(&base)?; + let container = base.join(container_name); + let root = container.join(digest); + let root = root.as_path(); + ensure_private_dir(&container)?; ensure_private_dir(root)?; for script in SCRIPTS { let path = root.join(script.path); @@ -238,6 +247,35 @@ fn materialize(root: &Path) -> io::Result { static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); +/// Every directory from `base` up to `/` must belong to this user or root. +/// One that every user can write to must be a root-owned sticky directory +/// (like `/tmp`), where no other user can rename our entries. Group write is +/// accepted on this user's own directories (umask 002 with a private group). +#[cfg(unix)] +fn check_trusted_ancestors(base: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + let uid = crate::platform::effective_uid(); + for dir in base.ancestors() { + let metadata = fs::symlink_metadata(dir)?; + let mode = metadata.mode(); + let owner_ok = metadata.uid() == uid || metadata.uid() == 0; + let shared = mode & 0o002 != 0 || (mode & 0o020 != 0 && metadata.uid() != uid); + let sticky_root = mode & 0o1000 != 0 && metadata.uid() == 0; + if !metadata.is_dir() || !owner_ok || (shared && !sticky_root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} could be replaced by another user", dir.display()), + )); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_trusted_ancestors(_base: &Path) -> io::Result<()> { + Ok(()) +} + fn ensure_private_dir(dir: &Path) -> io::Result<()> { match fs::create_dir(dir) { Ok(()) => {} @@ -369,6 +407,8 @@ mod tests { std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() )); + fs::create_dir_all(&base).unwrap(); + let base = fs::canonicalize(&base).unwrap(); let root = base.join("shell-integration").join(content_digest()); materialize(&root).unwrap(); for script in SCRIPTS { @@ -399,6 +439,26 @@ mod tests { fs::remove_dir_all(&base).unwrap(); } + #[cfg(unix)] + #[test] + fn scripts_are_refused_below_a_directory_others_can_replace() { + use std::os::unix::fs::PermissionsExt; + let base = std::env::temp_dir().join(format!( + "cmux-tui-shell-integration-shared-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(&base).unwrap(); + let base = fs::canonicalize(&base).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(0o777)).unwrap(); + let root = base.join("shell-integration").join(content_digest()); + assert!(materialize(&root).is_err()); + assert!(!base.join("shell-integration").exists()); + fs::set_permissions(&base, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(materialize(&root).unwrap(), root); + fs::remove_dir_all(&base).unwrap(); + } + #[test] fn opt_out_leaves_the_launch_unchanged() { let launch = diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 1115ac42bc7d..1d3c16eb66c2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -8274,9 +8274,19 @@ mod tests { std::thread::sleep(Duration::from_millis(5)); } surface.resize(60, 20).unwrap(); - std::thread::sleep(Duration::from_millis(500)); + // Wait for the shell's post-resize redraws to settle: the prompt + // text already matched before the resizes started. + let mut previous = String::new(); + let mut stable_reads = 0; let text = wait_for_viewport(&surface, "the settled prompt", |text, _| { - text.contains("prompt> nightly") + if text == previous { + stable_reads += 1; + } else { + previous = text.to_string(); + stable_reads = 0; + } + std::thread::sleep(Duration::from_millis(100)); + stable_reads >= 5 && text.contains("prompt> nightly") }); let at_prompt = surface.try_with_terminal(|terminal| terminal.cursor_is_at_prompt()).unwrap(); diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index d9cd2eabf3b2..e50eb9a9adec 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -2074,7 +2074,7 @@ tend to conflict together during rebases. - Keep `cursorBreakWrapIntoRow` limited to primary prompts: continuation and right prompts must keep their wrap. -The current cmux pin is the merged head `34cbf180d`, which merges the surface +An earlier cmux pin was the merged head `34cbf180d`, which merges the surface registry serialization (`e5c962a72`, section 14, landed on cmux `main` via branch `issue-5458-surface-registry-lock`) into the Cmd-click link fix line (`df789cd4b`, section 13) on top of the iOS render bounded-acquire pin From 887b3623d71de96ec5ff207e4501f07d3b54a4b9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:25:30 -0700 Subject: [PATCH 11/13] test(ci): a shallow submodule clone must not read as a diverged gitlink CI checks submodules out with fetch-depth 2. A forward bump that spans more history leaves both commits present with no ancestry between them, and the forward-only guard called that diverged, failing #15924's 9961d09be -> c318e7825 Ghostty bump (7 commits forward). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_submodule_forward_only.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_submodule_forward_only.py b/tests/test_submodule_forward_only.py index 9a8bdb0bba57..382d9400bedf 100644 --- a/tests/test_submodule_forward_only.py +++ b/tests/test_submodule_forward_only.py @@ -134,6 +134,19 @@ def test_undecidable_ancestry_fails(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("could not determine ancestry", result.stderr) + def test_shallow_clone_gap_is_not_divergence(self) -> None: + # CI checks submodules out shallowly. When a forward bump spans more + # history than the clone holds, both commits exist locally but no + # ancestry connects them; the local check must defer to GitHub. + self.commit_sub("middle subject") + tip = self.commit_sub("tip subject") + shallow = self.root / "shallow" + git("-c", "protocol.file.allow=always", "clone", "-q", "--depth", "1", + f"file://{self.subrepo}", str(shallow), cwd=self.root) + git("fetch", "-q", "--depth", "1", "origin", self.a, cwd=shallow) + self.assertEqual(git("rev-parse", "--is-shallow-repository", cwd=shallow), "true") + self.assertIsNone(submodule_forward_only.local_relation(str(shallow), self.a, tip)) + def test_declared_rollback_passes(self) -> None: b = self.commit_sub("intentional rollback") self.pointer(b) From d83e2c556a4c2a48ecd41dc55af670e8dde6f80f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:26:08 -0700 Subject: [PATCH 12/13] fix(ci): defer shallow submodule ancestry gaps to GitHub's compare API When both gitlinks exist in a shallow submodule clone but neither ancestry check succeeds, the clone is missing history, not proof of divergence. Return no local verdict so the guard asks GitHub. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/submodule_forward_only.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/ci/submodule_forward_only.py b/scripts/ci/submodule_forward_only.py index bba860e71be8..5174f4042405 100755 --- a/scripts/ci/submodule_forward_only.py +++ b/scripts/ci/submodule_forward_only.py @@ -59,6 +59,11 @@ def local_relation(path: str, base: str, new: str) -> str | None: base_exists = run("git", "-C", path, "cat-file", "-e", f"{base}^{{commit}}") new_exists = run("git", "-C", path, "cat-file", "-e", f"{new}^{{commit}}") if base_exists.returncode == 0 and new_exists.returncode == 0: + # A shallow clone cuts the history between the two commits, so a + # failed ancestry check there proves nothing; let GitHub decide. + shallow = run("git", "-C", path, "rev-parse", "--is-shallow-repository") + if shallow.stdout.strip() == "true": + return None return "diverged" return None From 62713a26a01c12b64c5b260640c89c7acbe72bda Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:50:17 -0700 Subject: [PATCH 13/13] Pin the GhosttyKit archive for Ghostty 9c1e67c07 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/ghostty-fork.md | 6 ++++-- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index b92956ead278..c1b18afd5094 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -151,8 +151,10 @@ The submodule pinned by this branch is `9c1e67c07`, a merge of `c318e7825` (the 133;P prompt and wrap padding fix, section 16, manaflow-ai/ghostty#247) and `559740279` (the VT replay blank-cell style fix, manaflow-ai/ghostty#249), landed on fork main by manaflow-ai/ghostty#250. It carries `e1b8bf5f4` again; -see [#16040](https://github.com/manaflow-ai/cmux/issues/16040). Artifact: -GHOSTTYKIT_ARTIFACT_PENDING. +see [#16040](https://github.com/manaflow-ai/cmux/issues/16040). Artifact +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-9c1e67c073cce77d7c2bb2592b2bf84d502bb74e-crashsubdir-cmux-crash-sentry-off-noi18n-v2 +has SHA-256 `4538cfea411ca43a420055594b96bcaf7e7b9bea70cb68fb669e2f1242e098d4`, pinned in +`scripts/ghosttykit-checksums.txt`. Earlier: the submodule pinned by this branch is `559740279`, the VT replay blank-cell style fix (manaflow-ai/ghostty#249) on top of `9d8d40319`, which is `9961d09be` plus its styled blank row test fix. It leaves out `e1b8bf5f4`: with it, cmux diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index e6baf64b33b8..fc197d41036b 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -140,3 +140,4 @@ e168fd31c0fc5893cdac933dc665307b3a760554 66d0089dcb7ea8873d86553e684e9b746d39c33 e1b8bf5f478c6aadbf70e51cdbb41930e92fda10 d18c7ddcc9f503cf2b03dff07b7001f4fc04f60d3d22bf84b2b4d5d5ce9ec885 c318e78253762d8c6eb7e117d5929b79a562a444 3c97a9cf83e4d052e3fa589542210e96f61994b51589e11c1c527f74239d1540 55974027991e4230210712796cd87c7b3a2018ee fadcca35636c45690dcfd180d0889f0adc3b0e636fadf7a97c74b14017031cf8 +9c1e67c073cce77d7c2bb2592b2bf84d502bb74e 4538cfea411ca43a420055594b96bcaf7e7b9bea70cb68fb669e2f1242e098d4