1use std::ffi::OsString;
13use std::fs::OpenOptions;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17use anyhow::{ensure, Context, Result};
18use serde::{Deserialize, Serialize};
19
20use crate::config::HarnessMapping;
21use crate::harness::{self, Harness};
22
23#[cfg(any(target_os = "linux", test))]
24mod linux_stat;
25#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
26mod pty_tests;
27
28#[cfg(target_os = "linux")]
29#[path = "linux.rs"]
30mod platform;
31#[cfg(target_os = "macos")]
32#[path = "macos.rs"]
33mod platform;
34
35const MAX_PROCESSES: usize = 16_384;
36const MAX_ARG_BYTES: usize = 256 * 1024;
37const MAX_ARGS: usize = 4096;
38const MAX_SHELLS: usize = 16;
39const MAX_MEMBERS: usize = 64;
40const MAX_FDS: usize = 4096;
41const BUDGET: Duration = Duration::from_millis(300);
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct Decision {
45 pub eligible: bool,
46 pub reason: &'static str,
47 pub invocation: Option<Invocation>,
49}
50
51impl Decision {
52 fn no(reason: &'static str) -> Self {
53 Self {
54 eligible: false,
55 reason,
56 invocation: None,
57 }
58 }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct Invocation {
65 frontend: ProcessKey,
66 wrapper: Option<ProcessKey>,
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
70struct ProcessKey {
71 pid: u32,
72 started: (u64, u64),
73}
74
75impl From<&Identity> for ProcessKey {
76 fn from(identity: &Identity) -> Self {
77 Self {
78 pid: identity.pid,
79 started: identity.started,
80 }
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum Liveness {
86 Alive,
88 Exited,
90 Unknown,
92}
93
94impl Invocation {
95 pub fn liveness(&self) -> Liveness {
98 #[cfg(any(target_os = "linux", target_os = "macos"))]
99 {
100 self.liveness_with(platform::identity)
101 }
102 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
103 {
104 Liveness::Unknown
105 }
106 }
107
108 fn liveness_with(&self, mut read: impl FnMut(u32) -> Result<Option<Identity>>) -> Liveness {
109 let mut result = Liveness::Alive;
110 for key in std::iter::once(&self.frontend).chain(self.wrapper.iter()) {
111 if key.pid == 0 || key.pid > i32::MAX as u32 {
113 result = Liveness::Unknown;
114 continue;
115 }
116 match read(key.pid) {
117 Ok(None) => return Liveness::Exited,
118 Ok(Some(identity)) if identity.pid == key.pid => {
119 if identity.started != key.started || identity.liveness == Liveness::Exited {
120 return Liveness::Exited;
121 }
122 if identity.liveness == Liveness::Unknown {
123 result = Liveness::Unknown;
124 }
125 }
126 Ok(Some(_)) | Err(_) => result = Liveness::Unknown,
127 }
128 }
129 result
130 }
131}
132
133#[cfg(any(target_os = "linux", target_os = "macos"))]
136fn confirm_missing(pid: u32) -> Result<Option<Identity>> {
137 ensure!(pid > 0 && pid <= i32::MAX as u32, "invalid process PID");
138 let result = unsafe { libc::kill(pid as i32, 0) };
140 if result < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
141 return Ok(None);
142 }
143 anyhow::bail!("process metadata unavailable without confirmed process exit")
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
147struct Identity {
148 pid: u32,
149 parent: u32,
150 group: u32,
151 tty: u64,
152 foreground: u32,
153 started: (u64, u64),
154 runnable: bool,
155 liveness: Liveness,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
159struct Process {
160 identity: Identity,
161 executable: PathBuf,
162 executable_id: ExecutableId,
163 argv: Vec<OsString>,
164 descriptors: Option<Descriptors>,
165}
166
167#[derive(Clone, Debug, Default, Eq, PartialEq)]
169struct ExecutableId {
170 device: u64,
171 inode: u64,
172 size: u64,
173 modified: (i64, i64),
174 changed: (i64, i64),
175}
176
177impl ExecutableId {
178 fn from_metadata(metadata: &std::fs::Metadata) -> Self {
179 use std::os::unix::fs::MetadataExt;
180 Self {
181 device: metadata.dev(),
182 inode: metadata.ino(),
183 size: metadata.len(),
184 modified: (metadata.mtime(), metadata.mtime_nsec()),
185 changed: (metadata.ctime(), metadata.ctime_nsec()),
186 }
187 }
188}
189
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191enum Input {
192 Pipe,
193 Socket,
194 Null,
195 Other,
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
201struct Descriptors {
202 stdin: Input,
203 extra_terminal: bool,
204}
205
206impl Descriptors {
207 fn helper(&self) -> bool {
208 self.stdin != Input::Other && !self.extra_terminal
209 }
210}
211
212#[cfg(any(target_os = "linux", target_os = "macos"))]
213fn harmless_devices() -> Result<[u64; 3]> {
214 use std::os::unix::fs::{FileTypeExt, MetadataExt};
215 let mut devices = [0; 3];
216 for (index, path) in ["/dev/null", "/dev/random", "/dev/urandom"]
217 .iter()
218 .enumerate()
219 {
220 let metadata = std::fs::metadata(path).context("cannot identify nonterminal devices")?;
221 ensure!(
222 metadata.file_type().is_char_device(),
223 "invalid nonterminal device"
224 );
225 devices[index] = platform::device(metadata.rdev());
226 }
227 Ok(devices)
228}
229
230#[derive(Clone, Debug, Eq, PartialEq)]
231struct Snapshot {
232 pane: Process,
233 members: Vec<Process>,
234 shells: Vec<Process>,
235}
236
237fn within_budget(start: Instant) -> Result<()> {
238 ensure!(
239 start.elapsed() < BUDGET,
240 "process inspection budget exceeded"
241 );
242 Ok(())
243}
244
245pub fn eligible(pane_pid: u32, pane_tty: &Path, mappings: &[HarnessMapping]) -> Result<Decision> {
249 #[cfg(any(target_os = "linux", target_os = "macos"))]
250 {
251 inspect(pane_pid, pane_tty, mappings)
252 }
253 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
254 {
255 let _ = (pane_pid, pane_tty, mappings);
256 anyhow::bail!("process inspection is supported only on macOS and Linux")
257 }
258}
259
260pub fn foreground_nvim(pane_pid: u32, pane_tty: &Path) -> Result<bool> {
263 #[cfg(any(target_os = "linux", target_os = "macos"))]
264 {
265 use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
266
267 ensure!(
268 pane_pid > 0 && pane_pid <= i32::MAX as u32,
269 "invalid pane PID"
270 );
271 let start = Instant::now();
272 let tty = OpenOptions::new()
273 .read(true)
274 .custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK | libc::O_CLOEXEC)
275 .open(pane_tty)?;
276 let metadata = tty.metadata()?;
277 ensure!(
278 metadata.file_type().is_char_device(),
279 "invalid pane terminal"
280 );
281 let device = platform::device(metadata.rdev());
282 let read = |pid| load(platform::identity(pid)?.context("process disappeared")?);
283 let capture = || -> Result<_> {
284 within_budget(start)?;
285 let pane = read(pane_pid)?;
286 let group = pane.identity.foreground;
287 ensure!(
288 pane.identity.tty == device && group > 0,
289 "foreground terminal changed"
290 );
291 let leader = read(group)?;
292 ensure!(
293 leader.identity.group == group
294 && leader.identity.foreground == group
295 && leader.identity.tty == device
296 && leader.identity.runnable,
297 "foreground leader is not running on the pane terminal"
298 );
299 let shells = shell_chain(&pane, &leader, group, read)?;
300 Ok((pane, leader, shells))
301 };
302 let first = capture()?;
303 ensure!(
304 first == capture()?,
305 "foreground job changed during inspection"
306 );
307 within_budget(start)?;
308 Ok(first.1.executable.file_name() == Some(std::ffi::OsStr::new("nvim")))
309 }
310 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
311 {
312 let _ = (pane_pid, pane_tty);
313 anyhow::bail!("process inspection is supported only on macOS and Linux")
314 }
315}
316
317#[cfg(any(target_os = "linux", target_os = "macos"))]
318fn foreground(pane_pid: u32, device: u64) -> Result<u32> {
319 let pane = platform::identity(pane_pid)?.context("pane process disappeared")?;
322 ensure!(
323 pane.tty == device && pane.foreground > 0,
324 "cannot determine pane foreground process group"
325 );
326 Ok(pane.foreground)
327}
328
329#[cfg(any(target_os = "linux", target_os = "macos"))]
330fn load(identity: Identity) -> Result<Process> {
331 let pid = identity.pid;
332 let (executable, executable_id) = platform::executable(pid)?;
333 let executable = executable
334 .canonicalize()
335 .context("cannot resolve process executable")?;
336 ensure!(
337 ExecutableId::from_metadata(
338 &std::fs::metadata(&executable).context("cannot stat process executable")?
339 ) == executable_id,
340 "process executable changed"
341 );
342 ensure!(
343 platform::identity(pid)?.as_ref() == Some(&identity),
344 "process changed during inspection"
345 );
346 Ok(Process {
347 identity,
348 executable,
349 executable_id,
350 argv: Vec::new(),
351 descriptors: None,
352 })
353}
354
355#[cfg(any(target_os = "linux", target_os = "macos"))]
356fn load_arguments(process: &mut Process) -> Result<()> {
357 let mut argv = platform::arguments(process.identity.pid)?;
358 if matches!(
359 process.executable.file_name().and_then(|s| s.to_str()),
360 Some("node" | "nodejs")
361 ) {
362 if let Some(entry) = argv.get_mut(1) {
363 let path = Path::new(entry);
364 if path.is_absolute() {
365 *entry = path
366 .canonicalize()
367 .context("cannot resolve Node entrypoint")?
368 .into_os_string();
369 }
370 }
371 }
372 ensure!(
373 platform::identity(process.identity.pid)?.as_ref() == Some(&process.identity),
374 "process changed during inspection"
375 );
376 process.argv = argv;
377 Ok(())
378}
379
380fn shell_chain(
381 pane: &Process,
382 leader: &Process,
383 group: u32,
384 mut read: impl FnMut(u32) -> Result<Process>,
385) -> Result<Vec<Process>> {
386 let mut shells: Vec<Process> = Vec::new();
387 if leader.identity.pid == pane.identity.pid {
388 return Ok(shells);
389 }
390 let mut parent = leader.identity.parent;
391 loop {
392 ensure!(
393 shells.len() < MAX_SHELLS,
394 "pane shell ancestry exceeds inspection limit"
395 );
396 ensure!(
397 parent > 0
398 && parent != leader.identity.pid
399 && !shells.iter().any(|p| p.identity.pid == parent),
400 "pane shell ancestry is disconnected or cyclic"
401 );
402 let shell = if parent == pane.identity.pid {
403 pane.clone()
404 } else {
405 read(parent)?
406 };
407 ensure!(
408 shell.identity.pid == parent,
409 "shell ancestor identity mismatch"
410 );
411 ensure!(
412 shell.identity.tty == pane.identity.tty
413 && shell.identity.foreground == group
414 && shell.identity.runnable
415 && harness::is_shell(&shell.executable),
416 "pane ancestor is not a live shell on the same foreground terminal"
417 );
418 parent = shell.identity.parent;
421 let reached_pane = shell.identity.pid == pane.identity.pid;
422 shells.push(shell);
423 if reached_pane {
424 return Ok(shells);
425 }
426 }
427}
428
429#[cfg(any(target_os = "linux", target_os = "macos"))]
430fn snapshot(
431 pane_pid: u32,
432 tty: u64,
433 group: u32,
434 mappings: &[HarnessMapping],
435 start: Instant,
436) -> Result<Snapshot> {
437 let pane = platform::identity(pane_pid)?.context("pane process disappeared")?;
438 ensure!(
439 pane.tty == tty && pane.foreground == group,
440 "pane terminal identity changed"
441 );
442 let mut members = Vec::new();
443 for identity in platform::list(group, start)? {
444 within_budget(start)?;
445 if identity.group == group {
446 ensure!(
447 identity.tty == tty && identity.foreground == group,
448 "foreground group terminal mismatch"
449 );
450 ensure!(
451 members.len() < MAX_MEMBERS,
452 "foreground member count exceeds inspection limit"
453 );
454 members.push(load(identity)?);
455 }
456 }
457 members.sort_by_key(|p| p.identity.pid);
458 if let Some(leader) = members.iter_mut().find(|p| p.identity.pid == group) {
459 if matches!(
460 leader.executable.file_name().and_then(|s| s.to_str()),
461 Some("node" | "nodejs")
462 ) || leader
463 .executable
464 .to_str()
465 .and_then(harness::native_layout)
466 .is_some()
467 || mappings.iter().any(|m| m.path == leader.executable)
468 {
469 load_arguments(leader)?;
470 }
471 let primary = harness::classify(&leader.executable, &leader.argv, mappings);
472 if let Some(primary) = primary
473 .filter(|p| p.node_wrapper && matches!(p.harness, Harness::Codex | Harness::OpenCode))
474 {
475 for child in members
476 .iter_mut()
477 .filter(|p| p.identity.pid != group && p.identity.parent == group)
478 {
479 within_budget(start)?;
480 let candidate = if mappings.iter().any(|m| m.path == child.executable) {
481 mappings
482 .iter()
483 .filter(|m| m.path == child.executable)
484 .all(|m| {
485 matches!(
486 (primary.harness, m.harness.as_str()),
487 (Harness::Codex, "codex") | (Harness::OpenCode, "opencode")
488 )
489 })
490 } else {
491 child.executable.to_str().and_then(harness::native_layout)
492 == Some(primary.harness)
493 };
494 if candidate
495 && !matches!(
496 child.executable.file_name().and_then(|s| s.to_str()),
497 Some("node" | "nodejs")
498 )
499 {
500 load_arguments(child)?;
501 }
502 }
503 }
504 }
505 let leader = members.iter().find(|p| p.identity.pid == group);
506 let frontends: Vec<_> = members
507 .iter()
508 .filter(|child| {
509 child.identity.pid == group
510 || leader.is_some_and(|leader| frontend_child(leader, child, mappings))
511 })
512 .map(|p| p.identity.pid)
513 .collect();
514 let devices = harmless_devices()?;
515 for member in members
516 .iter_mut()
517 .filter(|p| !frontends.contains(&p.identity.pid))
518 {
519 within_budget(start)?;
520 member.descriptors = Some(platform::descriptors(member.identity.pid, &devices, start)?);
521 ensure!(
522 platform::identity(member.identity.pid)?.as_ref() == Some(&member.identity),
523 "foreground member changed during FD inspection"
524 );
525 }
526 let pane = load(pane)?;
527 let shells = if let Some(leader) = members.iter().find(|p| p.identity.pid == group) {
528 shell_chain(&pane, leader, group, |pid| {
529 within_budget(start)?;
530 load(platform::identity(pid)?.context("shell ancestor disappeared")?)
531 })?
532 } else {
533 Vec::new()
534 };
535 within_budget(start)?;
536 Ok(Snapshot {
537 pane,
538 members,
539 shells,
540 })
541}
542
543#[cfg(any(target_os = "linux", target_os = "macos"))]
544fn inspect(pane_pid: u32, pane_tty: &Path, mappings: &[HarnessMapping]) -> Result<Decision> {
545 use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
546
547 ensure!(
548 pane_pid > 0 && pane_pid <= i32::MAX as u32,
549 "invalid pane PID"
550 );
551 let start = Instant::now();
552 let tty = OpenOptions::new()
553 .read(true)
554 .custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK | libc::O_CLOEXEC)
555 .open(pane_tty)
556 .context("cannot open pane terminal")?;
557 let metadata = tty.metadata().context("cannot stat pane terminal")?;
558 ensure!(
559 metadata.file_type().is_char_device(),
560 "pane terminal is not a character device"
561 );
562 let device = platform::device(metadata.rdev());
563 let group = foreground(pane_pid, device)?;
564 let mut canonical_mappings = Vec::with_capacity(mappings.len());
565 for mapping in mappings {
566 within_budget(start)?;
567 ensure!(
568 mapping.path.is_absolute(),
569 "harness mapping must be absolute"
570 );
571 canonical_mappings.push(HarnessMapping {
572 harness: mapping.harness.clone(),
573 path: mapping
574 .path
575 .canonicalize()
576 .context("cannot resolve harness mapping")?,
577 });
578 }
579 let first = snapshot(pane_pid, device, group, &canonical_mappings, start)?;
580 let decision = decide(&first.pane, &first.members, group, &canonical_mappings);
581 if !decision.eligible {
582 return Ok(decision);
583 }
584 ensure!(
585 foreground(pane_pid, device)? == group,
586 "foreground process group changed"
587 );
588 let second = snapshot(pane_pid, device, group, &canonical_mappings, start)?;
589 ensure!(
590 foreground(pane_pid, device)? == group,
591 "foreground job changed during inspection"
592 );
593 let decision = validated_decision(&first, &second, group, &canonical_mappings)?;
594 within_budget(start)?;
595 Ok(decision)
596}
597
598fn validated_decision(
599 first: &Snapshot,
600 second: &Snapshot,
601 group: u32,
602 mappings: &[HarnessMapping],
603) -> Result<Decision> {
604 ensure!(first == second, "foreground job changed during inspection");
605 let mut decision = decide(&first.pane, &first.members, group, mappings);
606 if decision.eligible {
607 let leader = first
608 .members
609 .iter()
610 .find(|p| p.identity.pid == group)
611 .expect("eligible job has a group leader");
612 let native = first
613 .members
614 .iter()
615 .find(|p| frontend_child(leader, p, mappings));
616 decision.invocation = Some(Invocation {
617 frontend: ProcessKey::from(&native.unwrap_or(leader).identity),
618 wrapper: native.map(|_| ProcessKey::from(&leader.identity)),
619 });
620 }
621 Ok(decision)
622}
623
624fn frontend_child(leader: &Process, child: &Process, mappings: &[HarnessMapping]) -> bool {
625 let Some(primary) = harness::classify(&leader.executable, &leader.argv, mappings) else {
626 return false;
627 };
628 let Some(secondary) = harness::classify(&child.executable, &child.argv, mappings) else {
629 return false;
630 };
631 primary.node_wrapper
632 && matches!(primary.harness, Harness::Codex | Harness::OpenCode)
633 && child.identity.pid != leader.identity.pid
634 && child.identity.parent == leader.identity.pid
635 && secondary.harness == primary.harness
636 && !secondary.node_wrapper
637 && leader.argv[primary.args_offset..] == child.argv[secondary.args_offset..]
638}
639
640fn decide(
641 pane: &Process,
642 members: &[Process],
643 group: u32,
644 mappings: &[HarnessMapping],
645) -> Decision {
646 if !pane.identity.runnable || members.is_empty() || members.iter().any(|p| !p.identity.runnable)
647 {
648 return Decision::no("no live foreground harness");
649 }
650 let Some(leader) = members.iter().find(|p| p.identity.pid == group) else {
651 return Decision::no("foreground group leader is not inspectable");
652 };
653 let Some(primary) = harness::classify(&leader.executable, &leader.argv, mappings) else {
655 return Decision::no("unrecognized harness or noninteractive arguments");
656 };
657 if members.len() > MAX_MEMBERS {
658 return Decision::no("foreground member count exceeds inspection limit");
659 }
660 let mut native = None;
661 if matches!(primary.harness, Harness::Codex | Harness::OpenCode) && primary.node_wrapper {
662 for child in members
663 .iter()
664 .filter(|p| p.identity.pid != group && p.identity.parent == group)
665 {
666 if frontend_child(leader, child, mappings)
667 && native.replace(child.identity.pid).is_some()
668 {
669 return Decision::no("multiple foreground native frontend candidates");
670 }
671 }
672 if native.is_none() {
673 return Decision::no("foreground processes are not a verified frontend pair");
674 }
675 }
676 for member in members
677 .iter()
678 .filter(|p| p.identity.pid != group && Some(p.identity.pid) != native)
679 {
680 let Some(fds) = &member.descriptors else {
681 return Decision::no("foreground helper FD metadata is unavailable");
682 };
683 if fds.stdin == Input::Other {
684 return Decision::no("foreground helper stdin is not a pipe, socket, or /dev/null");
685 }
686 if fds.extra_terminal {
687 return Decision::no("foreground helper has an extra terminal-capable FD");
688 }
689 let mut parent = member.identity.parent;
690 let mut seen = vec![member.identity.pid];
691 while parent != group && Some(parent) != native {
692 if seen.contains(&parent) || seen.len() >= MAX_MEMBERS {
693 return Decision::no("foreground helper ancestry is cyclic");
694 }
695 let Some(ancestor) = members.iter().find(|p| p.identity.pid == parent) else {
696 return Decision::no("foreground helper ancestry is missing or unrelated");
697 };
698 if !ancestor
699 .descriptors
700 .as_ref()
701 .is_some_and(Descriptors::helper)
702 {
703 return Decision::no(
704 "foreground helper ancestry includes a terminal-capable process",
705 );
706 }
707 seen.push(parent);
708 parent = ancestor.identity.parent;
709 }
710 }
711 Decision {
712 eligible: true,
713 invocation: None,
714 reason: if members.len() > 1 + usize::from(native.is_some()) {
715 "recognized interactive frontend with nonterminal same-group helpers"
716 } else if native.is_some() {
717 "recognized interactive wrapper/native frontend pair"
718 } else {
719 "recognized interactive foreground harness"
720 },
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use anyhow::bail;
728
729 fn process(pid: u32, parent: u32, exe: &str, argv: &[&str]) -> Process {
730 Process {
731 identity: Identity {
732 pid,
733 parent,
734 group: 20,
735 tty: 1,
736 foreground: 20,
737 started: (1, 0),
738 runnable: true,
739 liveness: Liveness::Alive,
740 },
741 executable: exe.into(),
742 executable_id: ExecutableId::default(),
743 argv: argv.iter().map(OsString::from).collect(),
744 descriptors: None,
745 }
746 }
747
748 #[test]
749 fn invocation_requires_matching_snapshots_but_ignores_helper_churn_between_inspections() {
750 let mut snapshot = Snapshot {
751 pane: process(10, 1, "/bin/bash", &["bash"]),
752 members: vec![process(
753 20,
754 10,
755 "/home/alice/.opencode/bin/opencode",
756 &["opencode"],
757 )],
758 shells: Vec::new(),
759 };
760 assert!(decide(&snapshot.pane, &snapshot.members, 20, &[])
761 .invocation
762 .is_none());
763 let original = validated_decision(&snapshot, &snapshot, 20, &[])
764 .unwrap()
765 .invocation
766 .unwrap();
767 assert_eq!(
768 original.frontend,
769 ProcessKey {
770 pid: 20,
771 started: (1, 0)
772 }
773 );
774 assert_eq!(original.wrapper, None);
775 let encoded = serde_json::to_string(&original).unwrap();
776 assert_eq!(
777 serde_json::from_str::<Invocation>(&encoded).unwrap(),
778 original
779 );
780
781 let mut helper = process(21, 20, "/usr/bin/node", &[]);
782 helper.descriptors = Some(Descriptors {
783 stdin: Input::Pipe,
784 extra_terminal: false,
785 });
786 snapshot.members.push(helper);
787 for pid in [21, 22, 23] {
788 snapshot.members[1].identity.pid = pid;
789 snapshot.members[1].identity.started.0 += 1;
790 assert_eq!(
791 validated_decision(&snapshot, &snapshot, 20, &[])
792 .unwrap()
793 .invocation,
794 Some(original)
795 );
796 }
797 let mut changed = snapshot.clone();
798 changed.members.pop();
799 assert!(validated_decision(&snapshot, &changed, 20, &[]).is_err());
800 assert_eq!(
801 validated_decision(&changed, &changed, 20, &[])
802 .unwrap()
803 .invocation,
804 Some(original)
805 );
806 for field in ["pid", "started", "subsecond"] {
807 let mut changed = snapshot.clone();
808 let mut group = 20;
809 match field {
810 "pid" => {
811 group = 30;
812 changed.members[0].identity.pid = group;
813 changed.members[1].identity.parent = group;
814 }
815 "started" => changed.members[0].identity.started.0 += 1,
816 "subsecond" => changed.members[0].identity.started.1 += 1,
817 _ => unreachable!(),
818 }
819 assert!(validated_decision(&snapshot, &changed, group, &[]).is_err());
820 assert_ne!(
821 validated_decision(&changed, &changed, group, &[])
822 .unwrap()
823 .invocation,
824 Some(original)
825 );
826 }
827 snapshot.members[0].identity.runnable = false;
828 let rejected = validated_decision(&snapshot, &snapshot, 20, &[]).unwrap();
829 assert!(!rejected.eligible);
830 assert_eq!(rejected.invocation, None);
831 }
832
833 #[test]
834 fn pair_invocation_tracks_native_and_wrapper_not_helpers() {
835 for (entry, executable, name) in [
836 (
837 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
838 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
839 "codex",
840 ),
841 (
842 "/usr/lib/node_modules/opencode-ai/bin/opencode",
843 "/usr/lib/node_modules/opencode-linux-x64/bin/opencode",
844 "opencode",
845 ),
846 ] {
847 let mut snapshot = Snapshot {
848 pane: process(10, 1, "/bin/bash", &["bash"]),
849 members: vec![
850 process(20, 10, "/usr/bin/node", &["node", entry]),
851 process(21, 20, executable, &[name]),
852 ],
853 shells: Vec::new(),
854 };
855 let invocation = validated_decision(&snapshot, &snapshot, 20, &[])
856 .unwrap()
857 .invocation
858 .unwrap();
859 assert_eq!(
860 invocation.frontend,
861 ProcessKey::from(&snapshot.members[1].identity)
862 );
863 assert_eq!(
864 invocation.wrapper,
865 Some(ProcessKey::from(&snapshot.members[0].identity))
866 );
867 let mut reads = Vec::new();
868 assert_eq!(
869 invocation.liveness_with(|pid| {
870 reads.push(pid);
871 Ok(snapshot
872 .members
873 .iter()
874 .find(|p| p.identity.pid == pid)
875 .map(|p| p.identity.clone()))
876 }),
877 Liveness::Alive
878 );
879 assert_eq!(reads, vec![21, 20]);
880 assert_eq!(
881 invocation.liveness_with(|pid| {
882 if pid == 20 {
883 bail!("wrapper metadata unavailable");
884 }
885 Ok(Some(snapshot.members[1].identity.clone()))
886 }),
887 Liveness::Unknown
888 );
889
890 let mut helper = process(22, 21, "/usr/bin/node", &[]);
891 helper.descriptors = Some(Descriptors {
892 stdin: Input::Socket,
893 extra_terminal: false,
894 });
895 snapshot.members.push(helper);
896 assert_eq!(
897 validated_decision(&snapshot, &snapshot, 20, &[])
898 .unwrap()
899 .invocation,
900 Some(invocation)
901 );
902 snapshot.members.reverse();
903 assert_eq!(
904 validated_decision(&snapshot, &snapshot, 20, &[])
905 .unwrap()
906 .invocation,
907 Some(invocation)
908 );
909 for pid in [20, 21] {
910 let mut changed = snapshot.clone();
911 changed
912 .members
913 .iter_mut()
914 .find(|p| p.identity.pid == pid)
915 .unwrap()
916 .identity
917 .started
918 .0 += 1;
919 assert_ne!(
920 validated_decision(&changed, &changed, 20, &[])
921 .unwrap()
922 .invocation,
923 Some(invocation)
924 );
925 assert_eq!(
926 invocation.liveness_with(|read_pid| {
927 Ok(changed
928 .members
929 .iter()
930 .find(|p| p.identity.pid == read_pid)
931 .map(|p| p.identity.clone()))
932 }),
933 Liveness::Exited
934 );
935 assert_eq!(
937 invocation.liveness_with(|read_pid| {
938 if read_pid == pid {
939 Ok(None)
940 } else {
941 bail!("permission denied")
942 }
943 }),
944 Liveness::Exited
945 );
946 }
947 }
948 }
949
950 #[test]
951 fn lifetime_distinguishes_stops_reuse_zombies_and_unknown_metadata() {
952 let mut identity = process(20, 10, "/bin/test", &[]).identity;
953 let invocation = Invocation {
954 frontend: ProcessKey::from(&identity),
955 wrapper: None,
956 };
957 identity.runnable = false;
958 identity.parent = 99;
959 identity.group = 99;
960 identity.tty = 0;
961 identity.foreground = 0;
962 assert_eq!(
963 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
964 Liveness::Alive
965 );
966 identity.liveness = Liveness::Exited;
967 assert_eq!(
968 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
969 Liveness::Exited
970 );
971 identity.liveness = Liveness::Unknown;
972 assert_eq!(
973 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
974 Liveness::Unknown
975 );
976 identity.started.0 += 1;
977 assert_eq!(
978 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
979 Liveness::Exited
980 );
981 identity.pid += 1;
982 assert_eq!(
983 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
984 Liveness::Unknown
985 );
986 assert_eq!(invocation.liveness_with(|_| Ok(None)), Liveness::Exited);
987 for errno in [
988 libc::EPERM,
989 libc::EACCES,
990 libc::EIO,
991 libc::ENOENT,
992 libc::ESRCH,
993 ] {
994 assert_eq!(
995 invocation.liveness_with(|_| Err(std::io::Error::from_raw_os_error(errno).into())),
996 Liveness::Unknown
997 );
998 }
999 for pid in [0, u32::MAX] {
1000 let invalid = Invocation {
1001 frontend: ProcessKey {
1002 pid,
1003 started: (1, 0),
1004 },
1005 wrapper: None,
1006 };
1007 assert_eq!(
1008 invalid.liveness_with(|_| panic!("invalid PID must not be queried")),
1009 Liveness::Unknown
1010 );
1011 }
1012 }
1013
1014 #[cfg(any(target_os = "linux", target_os = "macos"))]
1015 #[test]
1016 fn missing_metadata_does_not_imply_death() {
1017 assert!(confirm_missing(std::process::id()).is_err());
1018 assert!(confirm_missing(0).is_err());
1019 assert!(confirm_missing(u32::MAX).is_err());
1020 }
1021
1022 #[test]
1023 fn requires_live_group_leader() {
1024 let pane = process(10, 1, "/bin/zsh", &["zsh"]);
1025 let mut cli = process(
1026 20,
1027 10,
1028 "/home/alice/.local/share/claude/versions/2.1.1",
1029 &["claude"],
1030 );
1031 assert!(decide(&pane, &[cli.clone()], 20, &[]).eligible);
1032 cli.identity.runnable = false;
1033 assert!(!decide(&pane, &[cli.clone()], 20, &[]).eligible);
1034 cli.identity.runnable = true;
1035 assert!(!decide(&pane, &[cli.clone()], 21, &[]).eligible);
1036 assert!(decide(&cli, &[cli.clone()], 20, &[]).eligible);
1037 assert!(!decide(&pane, &[], 20, &[]).eligible);
1038 }
1039
1040 #[test]
1041 fn nested_shell_chain_is_bounded_and_shell_only() {
1042 let mut pane = process(10, 1, "/bin/zsh", &["zsh"]);
1043 pane.identity.group = 10;
1044 let cli = process(20, 15, "/home/alice/.opencode/bin/opencode", &["opencode"]);
1045 let mut nested = process(15, 10, "/bin/bash", &["bash"]);
1046 nested.identity.group = 15;
1047 let chain = shell_chain(&pane, &cli, 20, |_| Ok(nested.clone())).unwrap();
1048 assert_eq!(chain, vec![nested.clone(), pane.clone()]);
1049 let mut middle = process(14, 10, "/bin/zsh", &["zsh"]);
1050 middle.identity.group = 14;
1051 let mut deeper = nested.clone();
1052 deeper.identity.parent = 14;
1053 let longer = shell_chain(&pane, &cli, 20, |pid| match pid {
1054 15 => Ok(deeper.clone()),
1055 14 => Ok(middle.clone()),
1056 _ => panic!("must only read the parent chain"),
1057 })
1058 .unwrap();
1059 assert_eq!(longer, vec![deeper, middle, pane.clone()]);
1060 assert!(
1061 shell_chain(&cli, &cli, 20, |_| panic!("no ancestors for pane leader"))
1062 .unwrap()
1063 .is_empty()
1064 );
1065
1066 for exe in ["/usr/bin/nvim", "/usr/bin/ssh", "/tmp/bash"] {
1067 let mut invalid = nested.clone();
1068 invalid.executable = exe.into();
1069 assert!(shell_chain(&pane, &cli, 20, |_| Ok(invalid.clone())).is_err());
1070 }
1071 for field in [
1072 "tty",
1073 "foreground",
1074 "stopped",
1075 "cycle",
1076 "disconnected",
1077 "pid",
1078 ] {
1079 let mut invalid = nested.clone();
1080 match field {
1081 "tty" => invalid.identity.tty = 2,
1082 "foreground" => invalid.identity.foreground = 15,
1083 "stopped" => invalid.identity.runnable = false,
1084 "cycle" => invalid.identity.parent = 15,
1085 "disconnected" => invalid.identity.parent = 0,
1086 "pid" => invalid.identity.pid = 99,
1087 _ => unreachable!(),
1088 }
1089 assert!(
1090 shell_chain(&pane, &cli, 20, |_| Ok(invalid.clone())).is_err(),
1091 "{field}"
1092 );
1093 }
1094 assert!(shell_chain(&pane, &cli, 20, |_| bail!("ancestor disappeared")).is_err());
1095 let mut reads = 0;
1096 assert!(shell_chain(&pane, &cli, 20, |pid| {
1097 reads += 1;
1098 Ok(process(pid, pid + 100, "/bin/bash", &["bash"]))
1099 })
1100 .is_err());
1101 assert_eq!(reads, MAX_SHELLS);
1102
1103 let mut invalid_pane = pane.clone();
1104 invalid_pane.executable = "/usr/bin/nvim".into();
1105 assert!(shell_chain(&invalid_pane, &cli, 20, |_| Ok(nested.clone())).is_err());
1106 invalid_pane = pane.clone();
1107 invalid_pane.identity.runnable = false;
1108 assert!(shell_chain(&invalid_pane, &cli, 20, |_| Ok(nested.clone())).is_err());
1109
1110 let first = Snapshot {
1111 pane,
1112 members: vec![cli],
1113 shells: chain,
1114 };
1115 for field in ["started", "executable", "inode", "parent"] {
1116 let mut changed = first.clone();
1117 match field {
1118 "started" => changed.shells[0].identity.started.0 += 1,
1119 "executable" => changed.shells[0].executable = "/bin/zsh".into(),
1120 "inode" => changed.shells[0].executable_id.inode += 1,
1121 "parent" => changed.shells[0].identity.parent = 99,
1122 _ => unreachable!(),
1123 }
1124 assert_ne!(first, changed, "ancestor {field} must be revalidated");
1125 }
1126 }
1127
1128 #[test]
1129 fn foreground_shell_does_not_authorize_background_harness() {
1130 let mut pane = process(10, 1, "/bin/zsh", &["zsh"]);
1131 pane.identity.group = 10;
1132 pane.identity.foreground = 15;
1133 let mut shell = process(15, 10, "/bin/bash", &["bash"]);
1134 shell.identity.group = 15;
1135 shell.identity.foreground = 15;
1136 assert!(shell_chain(&pane, &shell, 15, |_| panic!("direct pane parent")).is_ok());
1138 assert!(!decide(&pane, &[shell], 15, &[]).eligible);
1139 }
1140
1141 #[test]
1142 fn only_codex_wrapper_native_pair_is_allowed() {
1143 let pane = process(10, 1, "/bin/bash", &["bash"]);
1144 let wrapper = process(
1145 20,
1146 10,
1147 "/usr/bin/node",
1148 &[
1149 "node",
1150 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
1151 "-p",
1152 "work",
1153 ],
1154 );
1155 let mut native = process(
1156 21,
1157 20,
1158 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1159 &["codex", "-p", "work"],
1160 );
1161 assert!(!decide(&pane, &[wrapper.clone()], 20, &[]).eligible);
1162 assert!(decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1163 native.identity.parent = 10;
1164 assert!(!decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1165 native.identity.parent = 20;
1166 native.argv.push("exec".into());
1167 assert!(!decide(&pane, &[wrapper.clone(), native], 20, &[]).eligible);
1168 let other = process(22, 20, "/bin/sh", &["sh"]);
1169 assert!(!decide(&pane, &[wrapper, other], 20, &[]).eligible);
1170 }
1171
1172 #[test]
1173 fn opencode_pair_requires_matching_interactive_arguments() {
1174 let pane = process(10, 1, "/bin/bash", &["bash"]);
1175 let wrapper = process(
1176 20,
1177 10,
1178 "/usr/bin/node",
1179 &[
1180 "node",
1181 "/usr/lib/node_modules/opencode-ai/bin/opencode",
1182 "attach",
1183 "http://localhost:4096",
1184 ],
1185 );
1186 let native = process(
1187 21,
1188 20,
1189 "/usr/lib/node_modules/opencode-linux-x64/bin/opencode",
1190 &["opencode", "attach", "http://localhost:4096"],
1191 );
1192 assert!(!decide(&pane, &[wrapper.clone()], 20, &[]).eligible);
1193 assert!(decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1194 let extra = process(22, 20, "/bin/sh", &["sh"]);
1195 assert!(!decide(&pane, &[wrapper, native, extra], 20, &[]).eligible);
1196 }
1197
1198 #[test]
1199 fn helpers_need_live_bounded_ancestry_and_nonterminal_input() {
1200 let pane = process(10, 1, "/bin/bash", &[]);
1201 let leader = process(20, 10, "/home/alice/.opencode/bin/opencode", &["opencode"]);
1202 let mut members = vec![leader];
1203 for pid in 21..20 + MAX_MEMBERS as u32 {
1204 let mut helper = process(pid, pid - 1, "/usr/bin/node", &[]);
1205 helper.descriptors = Some(Descriptors {
1206 stdin: [Input::Pipe, Input::Socket, Input::Null][pid as usize % 3],
1207 extra_terminal: false,
1208 });
1209 members.push(helper);
1210 }
1211 assert!(decide(&pane, &members, 20, &[]).eligible);
1212 for field in [
1214 "stdin",
1215 "extra",
1216 "missing",
1217 "unrelated",
1218 "self-cycle",
1219 "cycle",
1220 "stopped",
1221 "unreadable",
1222 ] {
1223 let mut invalid = members.clone();
1224 match field {
1225 "stdin" => invalid[1].descriptors.as_mut().unwrap().stdin = Input::Other,
1226 "extra" => invalid[1].descriptors.as_mut().unwrap().extra_terminal = true,
1227 "missing" => invalid[1].identity.parent = 999,
1228 "unrelated" => invalid[1].identity.parent = 10,
1229 "self-cycle" => invalid[1].identity.parent = 21,
1230 "cycle" => invalid[1].identity.parent = 22,
1231 "stopped" => invalid[1].identity.runnable = false,
1232 "unreadable" => invalid[1].descriptors = None,
1233 _ => unreachable!(),
1234 }
1235 assert!(!decide(&pane, &invalid, 20, &[]).eligible, "{field}");
1236 }
1237 let mut extra = members.last().unwrap().clone();
1238 extra.identity.pid += 1;
1239 members.push(extra);
1240 assert!(!decide(&pane, &members, 20, &[]).eligible);
1241 }
1242
1243 #[test]
1244 fn wrapper_helpers_must_be_rooted_at_the_verified_pair() {
1245 let pane = process(10, 1, "/bin/bash", &[]);
1246 let wrapper = process(
1247 20,
1248 10,
1249 "/usr/bin/node",
1250 &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js"],
1251 );
1252 let native = process(
1253 21,
1254 20,
1255 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1256 &["codex"],
1257 );
1258 let mut helper = process(22, 21, "/usr/bin/node", &[]);
1259 helper.descriptors = Some(Descriptors {
1260 stdin: Input::Pipe,
1261 extra_terminal: false,
1262 });
1263 let mut members = vec![wrapper, native, helper];
1264 assert!(decide(&pane, &members, 20, &[]).eligible);
1265 members[2].identity.parent = 20;
1266 assert!(decide(&pane, &members, 20, &[]).eligible);
1267 members[1].argv.push("exec".into());
1268 assert!(!decide(&pane, &members, 20, &[]).eligible);
1269 members[1].argv.pop();
1270 let mut duplicate = members[1].clone();
1271 duplicate.identity.pid = 23;
1272 members.push(duplicate);
1273 assert!(!decide(&pane, &members, 20, &[]).eligible);
1274 }
1275
1276 #[test]
1277 fn revalidation_retains_helper_identity_executable_and_relevant_fd_evidence() {
1278 let mut helper = process(21, 20, "/usr/bin/node", &[]);
1279 helper.descriptors = Some(Descriptors {
1280 stdin: Input::Pipe,
1281 extra_terminal: false,
1282 });
1283 for field in [
1284 "started",
1285 "parent",
1286 "executable",
1287 "inode",
1288 "mtime",
1289 "stdin",
1290 "terminal",
1291 ] {
1292 let mut changed = helper.clone();
1293 match field {
1294 "started" => changed.identity.started.0 += 1,
1295 "parent" => changed.identity.parent += 1,
1296 "executable" => changed.executable = "/usr/bin/editor".into(),
1297 "inode" => changed.executable_id.inode += 1,
1298 "mtime" => changed.executable_id.modified.0 += 1,
1299 "stdin" => changed.descriptors.as_mut().unwrap().stdin = Input::Socket,
1300 "terminal" => changed.descriptors.as_mut().unwrap().extra_terminal = true,
1301 _ => unreachable!(),
1302 }
1303 assert_ne!(helper, changed, "{field}");
1304 }
1305 assert!(within_budget(Instant::now() - BUDGET).is_err());
1306 }
1307}