diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
| commit | 82468a0e03360219898ebf3735bdf559182009f8 (patch) | |
| tree | 0ee5213fe041a539f23e846f9148493528249512 /src/pane/osc.rs | |
| parent | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff) | |
feat: add manifest-based detection engine
Diffstat (limited to 'src/pane/osc.rs')
| -rw-r--r-- | src/pane/osc.rs | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 23ab433a..e7b97a79 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -440,6 +440,263 @@ fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> { None } +/// Maximum retained string length for agent OSC title and progress payloads. +/// Title text is untrusted model output; cap it to bound memory and log size. +const AGENT_OSC_MAX_CHARS: usize = 256; + +/// Always-on tracker that retains the latest OSC 0/2 title and OSC 9 progress +/// payload emitted by the child process. Nothing here affects rendering; this +/// is pure passive capture for the detection engine (Stage C / Stage D). +/// +/// - `latest_title` — last OSC 0 or OSC 2 payload, sanitized. An empty +/// payload (e.g. `\x1b]0;\x07`) clears the stored value. +/// - `latest_progress` — last OSC 9 payload (the part after `9;`), stored +/// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`. +#[derive(Debug, Default)] +pub(super) struct AgentOscStateTracker { + state: Osc52ForwarderState, + body: Vec<u8>, + latest_title: Option<String>, + latest_progress: Option<String>, +} + +impl AgentOscStateTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some((command, payload)) = parse_agent_osc_body(&self.body) { + match command { + b"0" | b"2" => { + if payload.is_empty() { + self.latest_title = None; + } else { + self.latest_title = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + } + b"9" => { + self.latest_progress = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + _ => {} + } + } + self.body.clear(); + } + + /// Returns the latest retained OSC title, or `""` if none has been seen or + /// the last title was an empty clear. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_title(&self) -> &str { + self.latest_title.as_deref().unwrap_or("") + } + + /// Returns the latest retained OSC 9 progress payload, or `""` if none. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_progress(&self) -> &str { + self.latest_progress.as_deref().unwrap_or("") + } + + /// Drops the retained title and progress so a new foreground agent cannot + /// inherit OSC evidence emitted by a previous process. The in-flight parse + /// state is kept: a sequence spanning the agent change finalizes normally + /// and is attributed to the new agent. + pub(super) fn clear_retained(&mut self) { + self.latest_title = None; + self.latest_progress = None; + } +} + +/// Splits an OSC body at the first `;`, returning `(command, payload)`. +/// Returns `None` if there is no `;`. +fn parse_agent_osc_body(body: &[u8]) -> Option<(&[u8], &[u8])> { + let sep = body.iter().position(|&b| b == b';')?; + Some((&body[..sep], &body[sep + 1..])) +} + +fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String { + let text = String::from_utf8_lossy(payload); + let mut out = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(max_chars) { + out.push(ch); + } + out +} + +/// Reconstructs selected OSC sequences for local evidence capture while +/// debugging agent title/status behavior. This is intentionally passive: +/// nothing here affects terminal rendering or detection state. +#[derive(Debug)] +pub(super) struct OscDebugTracker { + enabled: bool, + state: Osc52ForwarderState, + body: Vec<u8>, + pending: Vec<OscDebugEvent>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct OscDebugEvent { + pub(super) command: String, + pub(super) payload: String, +} + +impl OscDebugTracker { + pub(super) fn from_env() -> Self { + Self { + enabled: osc_debug_enabled_from_env(), + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + + pub(super) fn observe(&mut self, bytes: &[u8]) { + if !self.enabled { + return; + } + + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some(event) = parse_osc_debug_event(&self.body) { + self.pending.push(event); + } + self.body.clear(); + } + + pub(super) fn drain_pending(&mut self) -> Vec<OscDebugEvent> { + std::mem::take(&mut self.pending) + } +} + +impl Default for OscDebugTracker { + fn default() -> Self { + Self::from_env() + } +} + +fn osc_debug_enabled_from_env() -> bool { + std::env::var("HERDR_DEBUG_OSC_EVIDENCE") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn parse_osc_debug_event(body: &[u8]) -> Option<OscDebugEvent> { + let separator = body.iter().position(|byte| *byte == b';')?; + let command = &body[..separator]; + let payload = &body[separator + 1..]; + if !matches!(command, b"0" | b"2" | b"9" | b"21337") { + return None; + } + Some(OscDebugEvent { + command: std::str::from_utf8(command).ok()?.to_string(), + payload: sanitized_osc_debug_payload(payload), + }) +} + +fn sanitized_osc_debug_payload(payload: &[u8]) -> String { + const MAX_CHARS: usize = 512; + let text = String::from_utf8_lossy(payload); + let mut sanitized = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(MAX_CHARS) { + sanitized.push(ch); + } + if text.chars().count() > MAX_CHARS { + sanitized.push_str("..."); + } + sanitized +} + fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> { let rest = uri.strip_prefix("file://")?; let path = if rest.starts_with('/') { @@ -724,6 +981,15 @@ mod tests { events.into_iter().map(|event| event.event).collect() } + fn enabled_osc_debug_tracker() -> OscDebugTracker { + OscDebugTracker { + enabled: true, + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + #[test] fn default_color_tracker_detects_split_osc_11_sequences() { let mut tracker = DefaultColorOscTracker::default(); @@ -766,6 +1032,235 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // AgentOscStateTracker tests + // ----------------------------------------------------------------------- + + #[test] + fn agent_osc_osc0_title_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]0;braille title\x07world".as_bytes()); + assert_eq!(t.latest_title(), "braille title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_osc2_title_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + assert_eq!(t.latest_title(), "static title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_empty_osc0_clears_title() { + let mut t = AgentOscStateTracker::default(); + // First set a title. + t.observe(b"\x1b]0;some title\x07"); + assert_eq!(t.latest_title(), "some title"); + // Then clear it with an empty payload (Codex pattern). + t.observe(b"\x1b]0;\x07"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_sets_progress_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_clear_progress_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + t.observe(b"\x1b]9;4;0;\x1b\\"); + assert_eq!(t.latest_progress(), "4;0;"); + } + + #[test] + fn agent_osc_split_sequence_across_chunks() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3"); + assert_eq!(t.latest_progress(), ""); + t.observe(b";\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_bel_and_st_terminators_both_work() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;title-bel\x07"); + assert_eq!(t.latest_title(), "title-bel"); + t.observe(b"\x1b]0;title-st\x1b\\"); + assert_eq!(t.latest_title(), "title-st"); + } + + #[test] + fn agent_osc_oversized_payload_is_discarded_and_recovers() { + let mut t = AgentOscStateTracker::default(); + // Set a title first. + t.observe(b"\x1b]0;before\x07"); + assert_eq!(t.latest_title(), "before"); + + // Feed an oversized OSC body (> 4096 bytes). + let mut oversized = Vec::from(b"\x1b]0;".as_slice()); + oversized.extend(std::iter::repeat_n(b'x', 4097)); + oversized.push(0x07); + t.observe(&oversized); + // The oversized body is dropped; the previously stored title is kept. + assert_eq!(t.latest_title(), "before"); + + // After recovery, subsequent valid sequences are captured normally. + t.observe(b"\x1b]0;after\x07"); + assert_eq!(t.latest_title(), "after"); + } + + #[test] + fn agent_osc_cap_length_is_respected() { + let mut t = AgentOscStateTracker::default(); + // Build a title of AGENT_OSC_MAX_CHARS + 50 ASCII chars. + let long_title: String = "a".repeat(AGENT_OSC_MAX_CHARS + 50); + let seq = format!("\x1b]0;{long_title}\x07"); + t.observe(seq.as_bytes()); + assert_eq!(t.latest_title().len(), AGENT_OSC_MAX_CHARS); + } + + #[test] + fn agent_osc_control_chars_stripped() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;before\x01after\x07"); + assert_eq!(t.latest_title(), "beforeafter"); + } + + #[test] + fn agent_osc_unrelated_osc_does_not_overwrite_title() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;my title\x07"); + // OSC 4 (palette color), OSC 52 (clipboard) — should not touch title/progress. + t.observe(b"\x1b]4;1;rgb:aa/bb/cc\x07"); + t.observe(b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(t.latest_title(), "my title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_interleaved_sequences() { + let mut t = AgentOscStateTracker::default(); + // OSC 0 title, then OSC 9 progress, then OSC 2 title update. + t.observe(b"\x1b]0;first\x07\x1b]9;4;3;\x07\x1b]2;second\x07"); + assert_eq!(t.latest_title(), "second"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_default_state_is_empty() { + let t = AgentOscStateTracker::default(); + assert_eq!(t.latest_title(), ""); + assert_eq!(t.latest_progress(), ""); + } + + // ----------------------------------------------------------------------- + // OscDebugTracker tests (existing) + // ----------------------------------------------------------------------- + + #[test] + fn osc_debug_tracker_detects_title_with_bel() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]0;✻ working title\x07world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "✻ working title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_title_with_st() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "2".to_string(), + payload: "static title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_split_status_sequences() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]9;4;3"); + assert!(tracker.drain_pending().is_empty()); + tracker.observe(b"\x07\x1b]21337;status=working\x1b\\"); + + assert_eq!( + tracker.drain_pending(), + vec![ + OscDebugEvent { + command: "9".to_string(), + payload: "4;3".to_string(), + }, + OscDebugEvent { + command: "21337".to_string(), + payload: "status=working".to_string(), + }, + ] + ); + } + + #[test] + fn osc_debug_tracker_ignores_untracked_osc_commands() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]52;c;SGVsbG8=\x07\x1b]7;file:///tmp\x07"); + + assert!(tracker.drain_pending().is_empty()); + } + + #[test] + fn osc_debug_tracker_sanitizes_control_characters() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]0;before\x01after\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "beforeafter".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_recovers_after_oversized_payload() { + let mut tracker = enabled_osc_debug_tracker(); + let oversized = vec![b'a'; 4097]; + + tracker.observe(b"\x1b]0;"); + tracker.observe(&oversized); + tracker.observe(b"\x07\x1b]0;ok\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "ok".to_string(), + }] + ); + } + #[test] fn default_color_event_tracker_detects_queries_sets_and_resets() { let mut tracker = DefaultColorEventTracker::default(); |
