Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions crates/noa-grid/src/osc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,21 +579,62 @@ 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.
None => true,
}
}

/// 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<String> {
static LOCAL_HOSTNAME: std::sync::OnceLock<Option<String>> = 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<Item = &'a str>,
) -> 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, 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, `<name>.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<Vec<String>> {
static OBSERVED: std::sync::Mutex<std::collections::VecDeque<String>> =
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());
// 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())
}

fn query_local_hostname() -> Option<String> {
Expand Down
38 changes: 33 additions & 5 deletions crates/noa-grid/src/tests/osc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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, `<name>.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");
Expand Down Expand Up @@ -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(),
Expand Down