From ae1a183901224663d5f2283b5850315d404d2661 Mon Sep 17 00:00:00 2001 From: simota Date: Thu, 10 Sep 2026 14:58:08 +0900 Subject: [PATCH 1/2] fix(grid): accept OSC 7 from every hostname the machine has gone by The OSC 7 host gate cached gethostname(2) once per process, but macOS rewrites the hostname on every network change (DHCP reverse-DNS name online, .local offline) while a shell's $HOST is frozen at its own startup. After a change every cwd report was rejected as non-local, so Terminal.cwd stayed None and new tabs, splits, and the scratch terminal no longer inherited the focused shell's directory. Re-query on each OSC 7 and match against a bounded set of every name observed during the process lifetime, so shells started before and after a change both pass. Claude-Session: https://claude.ai/code/session_01YTGJ217L3Zc1eZAGeyftpm --- crates/noa-grid/src/osc.rs | 54 +++++++++++++++++++++++++++----- crates/noa-grid/src/tests/osc.rs | 38 +++++++++++++++++++--- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/noa-grid/src/osc.rs b/crates/noa-grid/src/osc.rs index 9be44749..2910cd5c 100644 --- a/crates/noa-grid/src/osc.rs +++ b/crates/noa-grid/src/osc.rs @@ -579,8 +579,8 @@ pub(crate) fn hostname_matches_local(host: &str, local_hostname: &str) -> bool { } fn host_is_local(host: &str) -> bool { - match local_hostname() { - Some(local) => hostname_matches_local(host, &local), + match local_hostnames() { + Some(locals) => hostname_matches_any_local(host, locals.iter().map(String::as_str)), // Fail open (REQ-OSC-2): a false accept only risks showing a stale // proxy icon, while a false reject would break the shipped sidebar // cwd feature on a machine where hostname resolution is unavailable. @@ -588,12 +588,50 @@ fn host_is_local(host: &str) -> bool { } } -/// Cached across the process lifetime: the local hostname cannot change -/// without a reboot, so there's no need to re-syscall on every OSC 7 -/// sequence. -fn local_hostname() -> Option { - static LOCAL_HOSTNAME: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCAL_HOSTNAME.get_or_init(query_local_hostname).clone() +/// [`hostname_matches_local`] against every name this machine has gone by +/// during the process lifetime (see [`local_hostnames`]). Pure, for tests. +pub(crate) fn hostname_matches_any_local<'a>( + host: &str, + local_hostnames: impl IntoIterator, +) -> bool { + let mut locals = local_hostnames.into_iter().peekable(); + if locals.peek().is_none() { + return hostname_matches_local(host, ""); + } + locals.any(|local| hostname_matches_local(host, local)) +} + +/// Upper bound on remembered local hostnames (see [`local_hostnames`]). +/// Well above the handful of names a laptop cycles through in one session, +/// and small enough that the per-OSC-7 scan is free. +const OBSERVED_HOSTNAMES_CAP: usize = 8; + +/// Every hostname this process has seen `gethostname(2)` return, newest last, +/// bounded by [`OBSERVED_HOSTNAMES_CAP`] (oldest evicted). +/// +/// The hostname is *not* fixed for the process lifetime: macOS rewrites it on +/// every network change (DHCP/reverse-DNS name while online, `.local` +/// while offline), and a shell's `$HOST` is frozen at *its* startup. So a +/// single cached value rejects every OSC 7 from shells started after a +/// network change (or, if queried fresh each time, from shells started before +/// one), silently breaking new-tab cwd inheritance. Re-querying on each OSC 7 +/// is a cheap sysctl, and keeping every name observed accepts both shells that +/// predate and shells that postdate a change — fail-toward-accept, as the +/// REQ-OSC-2 gate intends. +fn local_hostnames() -> Option> { + static OBSERVED: std::sync::Mutex> = + std::sync::Mutex::new(std::collections::VecDeque::new()); + let current = query_local_hostname()?; + let mut observed = OBSERVED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !observed.contains(¤t) { + if observed.len() == OBSERVED_HOSTNAMES_CAP { + observed.pop_front(); + } + observed.push_back(current); + } + Some(observed.iter().cloned().collect()) } fn query_local_hostname() -> Option { diff --git a/crates/noa-grid/src/tests/osc.rs b/crates/noa-grid/src/tests/osc.rs index a356840c..cdfc60ab 100644 --- a/crates/noa-grid/src/tests/osc.rs +++ b/crates/noa-grid/src/tests/osc.rs @@ -20,7 +20,16 @@ fn explicit_agent_status_is_bounded_and_separate_from_notifications() { assert_eq!(clear.take_pending_agent_status(), Some(None)); let source = format!("\x1b]777;noa-agent;input;{}\x1b\\", "あ".repeat(300)); let mut bounded = run(source.as_bytes()); - assert_eq!(bounded.take_pending_agent_status().unwrap().unwrap().detail.chars().count(), 160); + assert_eq!( + bounded + .take_pending_agent_status() + .unwrap() + .unwrap() + .detail + .chars() + .count(), + 160 + ); } #[test] @@ -320,6 +329,28 @@ fn hostname_matches_local_accepts_case_insensitive_full_or_short_label_shapes() )); } +#[test] +fn hostname_matches_any_local_accepts_names_the_machine_went_by_earlier() { + // macOS renames the host on every network change (DHCP reverse-DNS name + // online, `.local` offline) while a shell's `$HOST` is frozen at + // its own startup. A shell started under either name must still be + // treated as local, or new-tab cwd inheritance silently breaks. + use crate::osc::hostname_matches_any_local; + + let seen = [ + "MacBook-Air.local", + "ip-192-168-0-64.ap-northeast-1.compute.internal", + ]; + assert!(hostname_matches_any_local("MacBook-Air.local", seen)); + assert!(hostname_matches_any_local("macbook-air", seen)); + assert!(hostname_matches_any_local("ip-192-168-0-64", seen)); + assert!(hostname_matches_any_local("localhost", seen)); + assert!(!hostname_matches_any_local("build-box.example.com", seen)); + // No observed name at all still honours the empty/localhost shortcuts. + assert!(hostname_matches_any_local("", [])); + assert!(!hostname_matches_any_local("MacBook-Air.local", [])); +} + #[test] fn osc133_prompt_marks_record_cursor_positions_and_exit_status() { let t = run(b"\x1b]133;A\x07$ \x1b]133;B\x07cmd\x1b]133;C\x07\x1b]133;D;7\x07"); @@ -514,10 +545,7 @@ fn osc9_4_error_and_pause_accept_optional_percentages() { fn osc9_4_state_four_is_paused() { let mut t = run(b"\x1b]9;4;4;20\x07"); let progress = t.progress().unwrap(); - assert!(matches!( - progress, - crate::TerminalProgress::Paused(Some(_)) - )); + assert!(matches!(progress, crate::TerminalProgress::Paused(Some(_)))); assert_eq!(progress.value().unwrap().get(), 20); assert_eq!( t.take_pending_progress_update(), From 25c8d2329ace1af8612c625dede62cba4206e81a Mon Sep 17 00:00:00 2001 From: simota Date: Thu, 10 Sep 2026 15:10:46 +0900 Subject: [PATCH 2/2] fix(grid): refresh re-observed hostnames before LRU eviction Claude-Session: https://claude.ai/code/session_01YTGJ217L3Zc1eZAGeyftpm --- crates/noa-grid/src/osc.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/noa-grid/src/osc.rs b/crates/noa-grid/src/osc.rs index 2910cd5c..fc2d3e48 100644 --- a/crates/noa-grid/src/osc.rs +++ b/crates/noa-grid/src/osc.rs @@ -606,8 +606,9 @@ pub(crate) fn hostname_matches_any_local<'a>( /// and small enough that the per-OSC-7 scan is free. const OBSERVED_HOSTNAMES_CAP: usize = 8; -/// Every hostname this process has seen `gethostname(2)` return, newest last, -/// bounded by [`OBSERVED_HOSTNAMES_CAP`] (oldest evicted). +/// Every hostname this process has seen `gethostname(2)` return, most +/// recently observed last, bounded by [`OBSERVED_HOSTNAMES_CAP`] (least +/// recently observed evicted). /// /// The hostname is *not* fixed for the process lifetime: macOS rewrites it on /// every network change (DHCP/reverse-DNS name while online, `.local` @@ -625,12 +626,14 @@ fn local_hostnames() -> Option> { let mut observed = OBSERVED .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !observed.contains(¤t) { - if observed.len() == OBSERVED_HOSTNAMES_CAP { - observed.pop_front(); - } - observed.push_back(current); - } + // LRU: a re-observed name moves to the back, so a name the machine keeps + // returning to is never the one evicted when the cap is hit. + if let Some(pos) = observed.iter().position(|seen| *seen == current) { + observed.remove(pos); + } else if observed.len() == OBSERVED_HOSTNAMES_CAP { + observed.pop_front(); + } + observed.push_back(current); Some(observed.iter().cloned().collect()) }