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
260#[cfg(any(target_os = "linux", target_os = "macos"))]
261fn foreground(pane_pid: u32, device: u64) -> Result<u32> {
262 let pane = platform::identity(pane_pid)?.context("pane process disappeared")?;
265 ensure!(
266 pane.tty == device && pane.foreground > 0,
267 "cannot determine pane foreground process group"
268 );
269 Ok(pane.foreground)
270}
271
272#[cfg(any(target_os = "linux", target_os = "macos"))]
273fn load(identity: Identity) -> Result<Process> {
274 let pid = identity.pid;
275 let (executable, executable_id) = platform::executable(pid)?;
276 let executable = executable
277 .canonicalize()
278 .context("cannot resolve process executable")?;
279 ensure!(
280 ExecutableId::from_metadata(
281 &std::fs::metadata(&executable).context("cannot stat process executable")?
282 ) == executable_id,
283 "process executable changed"
284 );
285 ensure!(
286 platform::identity(pid)?.as_ref() == Some(&identity),
287 "process changed during inspection"
288 );
289 Ok(Process {
290 identity,
291 executable,
292 executable_id,
293 argv: Vec::new(),
294 descriptors: None,
295 })
296}
297
298#[cfg(any(target_os = "linux", target_os = "macos"))]
299fn load_arguments(process: &mut Process) -> Result<()> {
300 let mut argv = platform::arguments(process.identity.pid)?;
301 if matches!(
302 process.executable.file_name().and_then(|s| s.to_str()),
303 Some("node" | "nodejs")
304 ) {
305 if let Some(entry) = argv.get_mut(1) {
306 let path = Path::new(entry);
307 if path.is_absolute() {
308 *entry = path
309 .canonicalize()
310 .context("cannot resolve Node entrypoint")?
311 .into_os_string();
312 }
313 }
314 }
315 ensure!(
316 platform::identity(process.identity.pid)?.as_ref() == Some(&process.identity),
317 "process changed during inspection"
318 );
319 process.argv = argv;
320 Ok(())
321}
322
323fn shell_chain(
324 pane: &Process,
325 leader: &Process,
326 group: u32,
327 mut read: impl FnMut(u32) -> Result<Process>,
328) -> Result<Vec<Process>> {
329 let mut shells: Vec<Process> = Vec::new();
330 if leader.identity.pid == pane.identity.pid {
331 return Ok(shells);
332 }
333 let mut parent = leader.identity.parent;
334 loop {
335 ensure!(
336 shells.len() < MAX_SHELLS,
337 "pane shell ancestry exceeds inspection limit"
338 );
339 ensure!(
340 parent > 0
341 && parent != leader.identity.pid
342 && !shells.iter().any(|p| p.identity.pid == parent),
343 "pane shell ancestry is disconnected or cyclic"
344 );
345 let shell = if parent == pane.identity.pid {
346 pane.clone()
347 } else {
348 read(parent)?
349 };
350 ensure!(
351 shell.identity.pid == parent,
352 "shell ancestor identity mismatch"
353 );
354 ensure!(
355 shell.identity.tty == pane.identity.tty
356 && shell.identity.foreground == group
357 && shell.identity.runnable
358 && harness::is_shell(&shell.executable),
359 "pane ancestor is not a live shell on the same foreground terminal"
360 );
361 parent = shell.identity.parent;
364 let reached_pane = shell.identity.pid == pane.identity.pid;
365 shells.push(shell);
366 if reached_pane {
367 return Ok(shells);
368 }
369 }
370}
371
372#[cfg(any(target_os = "linux", target_os = "macos"))]
373fn snapshot(
374 pane_pid: u32,
375 tty: u64,
376 group: u32,
377 mappings: &[HarnessMapping],
378 start: Instant,
379) -> Result<Snapshot> {
380 let pane = platform::identity(pane_pid)?.context("pane process disappeared")?;
381 ensure!(
382 pane.tty == tty && pane.foreground == group,
383 "pane terminal identity changed"
384 );
385 let mut members = Vec::new();
386 for identity in platform::list(group, start)? {
387 within_budget(start)?;
388 if identity.group == group {
389 ensure!(
390 identity.tty == tty && identity.foreground == group,
391 "foreground group terminal mismatch"
392 );
393 ensure!(
394 members.len() < MAX_MEMBERS,
395 "foreground member count exceeds inspection limit"
396 );
397 members.push(load(identity)?);
398 }
399 }
400 members.sort_by_key(|p| p.identity.pid);
401 if let Some(leader) = members.iter_mut().find(|p| p.identity.pid == group) {
402 if matches!(
403 leader.executable.file_name().and_then(|s| s.to_str()),
404 Some("node" | "nodejs")
405 ) || leader
406 .executable
407 .to_str()
408 .and_then(harness::native_layout)
409 .is_some()
410 || mappings.iter().any(|m| m.path == leader.executable)
411 {
412 load_arguments(leader)?;
413 }
414 let primary = harness::classify(&leader.executable, &leader.argv, mappings);
415 if let Some(primary) = primary
416 .filter(|p| p.node_wrapper && matches!(p.harness, Harness::Codex | Harness::OpenCode))
417 {
418 for child in members
419 .iter_mut()
420 .filter(|p| p.identity.pid != group && p.identity.parent == group)
421 {
422 within_budget(start)?;
423 let candidate = if mappings.iter().any(|m| m.path == child.executable) {
424 mappings
425 .iter()
426 .filter(|m| m.path == child.executable)
427 .all(|m| {
428 matches!(
429 (primary.harness, m.harness.as_str()),
430 (Harness::Codex, "codex") | (Harness::OpenCode, "opencode")
431 )
432 })
433 } else {
434 child.executable.to_str().and_then(harness::native_layout)
435 == Some(primary.harness)
436 };
437 if candidate
438 && !matches!(
439 child.executable.file_name().and_then(|s| s.to_str()),
440 Some("node" | "nodejs")
441 )
442 {
443 load_arguments(child)?;
444 }
445 }
446 }
447 }
448 let leader = members.iter().find(|p| p.identity.pid == group);
449 let frontends: Vec<_> = members
450 .iter()
451 .filter(|child| {
452 child.identity.pid == group
453 || leader.is_some_and(|leader| frontend_child(leader, child, mappings))
454 })
455 .map(|p| p.identity.pid)
456 .collect();
457 let devices = harmless_devices()?;
458 for member in members
459 .iter_mut()
460 .filter(|p| !frontends.contains(&p.identity.pid))
461 {
462 within_budget(start)?;
463 member.descriptors = Some(platform::descriptors(member.identity.pid, &devices, start)?);
464 ensure!(
465 platform::identity(member.identity.pid)?.as_ref() == Some(&member.identity),
466 "foreground member changed during FD inspection"
467 );
468 }
469 let pane = load(pane)?;
470 let shells = if let Some(leader) = members.iter().find(|p| p.identity.pid == group) {
471 shell_chain(&pane, leader, group, |pid| {
472 within_budget(start)?;
473 load(platform::identity(pid)?.context("shell ancestor disappeared")?)
474 })?
475 } else {
476 Vec::new()
477 };
478 within_budget(start)?;
479 Ok(Snapshot {
480 pane,
481 members,
482 shells,
483 })
484}
485
486#[cfg(any(target_os = "linux", target_os = "macos"))]
487fn inspect(pane_pid: u32, pane_tty: &Path, mappings: &[HarnessMapping]) -> Result<Decision> {
488 use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
489
490 ensure!(
491 pane_pid > 0 && pane_pid <= i32::MAX as u32,
492 "invalid pane PID"
493 );
494 let start = Instant::now();
495 let tty = OpenOptions::new()
496 .read(true)
497 .custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK | libc::O_CLOEXEC)
498 .open(pane_tty)
499 .context("cannot open pane terminal")?;
500 let metadata = tty.metadata().context("cannot stat pane terminal")?;
501 ensure!(
502 metadata.file_type().is_char_device(),
503 "pane terminal is not a character device"
504 );
505 let device = platform::device(metadata.rdev());
506 let group = foreground(pane_pid, device)?;
507 let mut canonical_mappings = Vec::with_capacity(mappings.len());
508 for mapping in mappings {
509 within_budget(start)?;
510 ensure!(
511 mapping.path.is_absolute(),
512 "harness mapping must be absolute"
513 );
514 canonical_mappings.push(HarnessMapping {
515 harness: mapping.harness.clone(),
516 path: mapping
517 .path
518 .canonicalize()
519 .context("cannot resolve harness mapping")?,
520 });
521 }
522 let first = snapshot(pane_pid, device, group, &canonical_mappings, start)?;
523 let decision = decide(&first.pane, &first.members, group, &canonical_mappings);
524 if !decision.eligible {
525 return Ok(decision);
526 }
527 ensure!(
528 foreground(pane_pid, device)? == group,
529 "foreground process group changed"
530 );
531 let second = snapshot(pane_pid, device, group, &canonical_mappings, start)?;
532 ensure!(
533 foreground(pane_pid, device)? == group,
534 "foreground job changed during inspection"
535 );
536 let decision = validated_decision(&first, &second, group, &canonical_mappings)?;
537 within_budget(start)?;
538 Ok(decision)
539}
540
541fn validated_decision(
542 first: &Snapshot,
543 second: &Snapshot,
544 group: u32,
545 mappings: &[HarnessMapping],
546) -> Result<Decision> {
547 ensure!(first == second, "foreground job changed during inspection");
548 let mut decision = decide(&first.pane, &first.members, group, mappings);
549 if decision.eligible {
550 let leader = first
551 .members
552 .iter()
553 .find(|p| p.identity.pid == group)
554 .expect("eligible job has a group leader");
555 let native = first
556 .members
557 .iter()
558 .find(|p| frontend_child(leader, p, mappings));
559 decision.invocation = Some(Invocation {
560 frontend: ProcessKey::from(&native.unwrap_or(leader).identity),
561 wrapper: native.map(|_| ProcessKey::from(&leader.identity)),
562 });
563 }
564 Ok(decision)
565}
566
567fn frontend_child(leader: &Process, child: &Process, mappings: &[HarnessMapping]) -> bool {
568 let Some(primary) = harness::classify(&leader.executable, &leader.argv, mappings) else {
569 return false;
570 };
571 let Some(secondary) = harness::classify(&child.executable, &child.argv, mappings) else {
572 return false;
573 };
574 primary.node_wrapper
575 && matches!(primary.harness, Harness::Codex | Harness::OpenCode)
576 && child.identity.pid != leader.identity.pid
577 && child.identity.parent == leader.identity.pid
578 && secondary.harness == primary.harness
579 && !secondary.node_wrapper
580 && leader.argv[primary.args_offset..] == child.argv[secondary.args_offset..]
581}
582
583fn decide(
584 pane: &Process,
585 members: &[Process],
586 group: u32,
587 mappings: &[HarnessMapping],
588) -> Decision {
589 if !pane.identity.runnable || members.is_empty() || members.iter().any(|p| !p.identity.runnable)
590 {
591 return Decision::no("no live foreground harness");
592 }
593 let Some(leader) = members.iter().find(|p| p.identity.pid == group) else {
594 return Decision::no("foreground group leader is not inspectable");
595 };
596 let Some(primary) = harness::classify(&leader.executable, &leader.argv, mappings) else {
598 return Decision::no("unrecognized harness or noninteractive arguments");
599 };
600 if members.len() > MAX_MEMBERS {
601 return Decision::no("foreground member count exceeds inspection limit");
602 }
603 let mut native = None;
604 if matches!(primary.harness, Harness::Codex | Harness::OpenCode) && primary.node_wrapper {
605 for child in members
606 .iter()
607 .filter(|p| p.identity.pid != group && p.identity.parent == group)
608 {
609 if frontend_child(leader, child, mappings)
610 && native.replace(child.identity.pid).is_some()
611 {
612 return Decision::no("multiple foreground native frontend candidates");
613 }
614 }
615 if native.is_none() {
616 return Decision::no("foreground processes are not a verified frontend pair");
617 }
618 }
619 for member in members
620 .iter()
621 .filter(|p| p.identity.pid != group && Some(p.identity.pid) != native)
622 {
623 let Some(fds) = &member.descriptors else {
624 return Decision::no("foreground helper FD metadata is unavailable");
625 };
626 if fds.stdin == Input::Other {
627 return Decision::no("foreground helper stdin is not a pipe, socket, or /dev/null");
628 }
629 if fds.extra_terminal {
630 return Decision::no("foreground helper has an extra terminal-capable FD");
631 }
632 let mut parent = member.identity.parent;
633 let mut seen = vec![member.identity.pid];
634 while parent != group && Some(parent) != native {
635 if seen.contains(&parent) || seen.len() >= MAX_MEMBERS {
636 return Decision::no("foreground helper ancestry is cyclic");
637 }
638 let Some(ancestor) = members.iter().find(|p| p.identity.pid == parent) else {
639 return Decision::no("foreground helper ancestry is missing or unrelated");
640 };
641 if !ancestor
642 .descriptors
643 .as_ref()
644 .is_some_and(Descriptors::helper)
645 {
646 return Decision::no(
647 "foreground helper ancestry includes a terminal-capable process",
648 );
649 }
650 seen.push(parent);
651 parent = ancestor.identity.parent;
652 }
653 }
654 Decision {
655 eligible: true,
656 invocation: None,
657 reason: if members.len() > 1 + usize::from(native.is_some()) {
658 "recognized interactive frontend with nonterminal same-group helpers"
659 } else if native.is_some() {
660 "recognized interactive wrapper/native frontend pair"
661 } else {
662 "recognized interactive foreground harness"
663 },
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use anyhow::bail;
671
672 fn process(pid: u32, parent: u32, exe: &str, argv: &[&str]) -> Process {
673 Process {
674 identity: Identity {
675 pid,
676 parent,
677 group: 20,
678 tty: 1,
679 foreground: 20,
680 started: (1, 0),
681 runnable: true,
682 liveness: Liveness::Alive,
683 },
684 executable: exe.into(),
685 executable_id: ExecutableId::default(),
686 argv: argv.iter().map(OsString::from).collect(),
687 descriptors: None,
688 }
689 }
690
691 #[test]
692 fn invocation_requires_matching_snapshots_but_ignores_helper_churn_between_inspections() {
693 let mut snapshot = Snapshot {
694 pane: process(10, 1, "/bin/bash", &["bash"]),
695 members: vec![process(
696 20,
697 10,
698 "/home/alice/.opencode/bin/opencode",
699 &["opencode"],
700 )],
701 shells: Vec::new(),
702 };
703 assert!(decide(&snapshot.pane, &snapshot.members, 20, &[])
704 .invocation
705 .is_none());
706 let original = validated_decision(&snapshot, &snapshot, 20, &[])
707 .unwrap()
708 .invocation
709 .unwrap();
710 assert_eq!(
711 original.frontend,
712 ProcessKey {
713 pid: 20,
714 started: (1, 0)
715 }
716 );
717 assert_eq!(original.wrapper, None);
718 let encoded = serde_json::to_string(&original).unwrap();
719 assert_eq!(
720 serde_json::from_str::<Invocation>(&encoded).unwrap(),
721 original
722 );
723
724 let mut helper = process(21, 20, "/usr/bin/node", &[]);
725 helper.descriptors = Some(Descriptors {
726 stdin: Input::Pipe,
727 extra_terminal: false,
728 });
729 snapshot.members.push(helper);
730 for pid in [21, 22, 23] {
731 snapshot.members[1].identity.pid = pid;
732 snapshot.members[1].identity.started.0 += 1;
733 assert_eq!(
734 validated_decision(&snapshot, &snapshot, 20, &[])
735 .unwrap()
736 .invocation,
737 Some(original)
738 );
739 }
740 let mut changed = snapshot.clone();
741 changed.members.pop();
742 assert!(validated_decision(&snapshot, &changed, 20, &[]).is_err());
743 assert_eq!(
744 validated_decision(&changed, &changed, 20, &[])
745 .unwrap()
746 .invocation,
747 Some(original)
748 );
749 for field in ["pid", "started", "subsecond"] {
750 let mut changed = snapshot.clone();
751 let mut group = 20;
752 match field {
753 "pid" => {
754 group = 30;
755 changed.members[0].identity.pid = group;
756 changed.members[1].identity.parent = group;
757 }
758 "started" => changed.members[0].identity.started.0 += 1,
759 "subsecond" => changed.members[0].identity.started.1 += 1,
760 _ => unreachable!(),
761 }
762 assert!(validated_decision(&snapshot, &changed, group, &[]).is_err());
763 assert_ne!(
764 validated_decision(&changed, &changed, group, &[])
765 .unwrap()
766 .invocation,
767 Some(original)
768 );
769 }
770 snapshot.members[0].identity.runnable = false;
771 let rejected = validated_decision(&snapshot, &snapshot, 20, &[]).unwrap();
772 assert!(!rejected.eligible);
773 assert_eq!(rejected.invocation, None);
774 }
775
776 #[test]
777 fn pair_invocation_tracks_native_and_wrapper_not_helpers() {
778 for (entry, executable, name) in [
779 (
780 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
781 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
782 "codex",
783 ),
784 (
785 "/usr/lib/node_modules/opencode-ai/bin/opencode",
786 "/usr/lib/node_modules/opencode-linux-x64/bin/opencode",
787 "opencode",
788 ),
789 ] {
790 let mut snapshot = Snapshot {
791 pane: process(10, 1, "/bin/bash", &["bash"]),
792 members: vec![
793 process(20, 10, "/usr/bin/node", &["node", entry]),
794 process(21, 20, executable, &[name]),
795 ],
796 shells: Vec::new(),
797 };
798 let invocation = validated_decision(&snapshot, &snapshot, 20, &[])
799 .unwrap()
800 .invocation
801 .unwrap();
802 assert_eq!(
803 invocation.frontend,
804 ProcessKey::from(&snapshot.members[1].identity)
805 );
806 assert_eq!(
807 invocation.wrapper,
808 Some(ProcessKey::from(&snapshot.members[0].identity))
809 );
810 let mut reads = Vec::new();
811 assert_eq!(
812 invocation.liveness_with(|pid| {
813 reads.push(pid);
814 Ok(snapshot
815 .members
816 .iter()
817 .find(|p| p.identity.pid == pid)
818 .map(|p| p.identity.clone()))
819 }),
820 Liveness::Alive
821 );
822 assert_eq!(reads, vec![21, 20]);
823 assert_eq!(
824 invocation.liveness_with(|pid| {
825 if pid == 20 {
826 bail!("wrapper metadata unavailable");
827 }
828 Ok(Some(snapshot.members[1].identity.clone()))
829 }),
830 Liveness::Unknown
831 );
832
833 let mut helper = process(22, 21, "/usr/bin/node", &[]);
834 helper.descriptors = Some(Descriptors {
835 stdin: Input::Socket,
836 extra_terminal: false,
837 });
838 snapshot.members.push(helper);
839 assert_eq!(
840 validated_decision(&snapshot, &snapshot, 20, &[])
841 .unwrap()
842 .invocation,
843 Some(invocation)
844 );
845 snapshot.members.reverse();
846 assert_eq!(
847 validated_decision(&snapshot, &snapshot, 20, &[])
848 .unwrap()
849 .invocation,
850 Some(invocation)
851 );
852 for pid in [20, 21] {
853 let mut changed = snapshot.clone();
854 changed
855 .members
856 .iter_mut()
857 .find(|p| p.identity.pid == pid)
858 .unwrap()
859 .identity
860 .started
861 .0 += 1;
862 assert_ne!(
863 validated_decision(&changed, &changed, 20, &[])
864 .unwrap()
865 .invocation,
866 Some(invocation)
867 );
868 assert_eq!(
869 invocation.liveness_with(|read_pid| {
870 Ok(changed
871 .members
872 .iter()
873 .find(|p| p.identity.pid == read_pid)
874 .map(|p| p.identity.clone()))
875 }),
876 Liveness::Exited
877 );
878 assert_eq!(
880 invocation.liveness_with(|read_pid| {
881 if read_pid == pid {
882 Ok(None)
883 } else {
884 bail!("permission denied")
885 }
886 }),
887 Liveness::Exited
888 );
889 }
890 }
891 }
892
893 #[test]
894 fn lifetime_distinguishes_stops_reuse_zombies_and_unknown_metadata() {
895 let mut identity = process(20, 10, "/bin/test", &[]).identity;
896 let invocation = Invocation {
897 frontend: ProcessKey::from(&identity),
898 wrapper: None,
899 };
900 identity.runnable = false;
901 identity.parent = 99;
902 identity.group = 99;
903 identity.tty = 0;
904 identity.foreground = 0;
905 assert_eq!(
906 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
907 Liveness::Alive
908 );
909 identity.liveness = Liveness::Exited;
910 assert_eq!(
911 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
912 Liveness::Exited
913 );
914 identity.liveness = Liveness::Unknown;
915 assert_eq!(
916 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
917 Liveness::Unknown
918 );
919 identity.started.0 += 1;
920 assert_eq!(
921 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
922 Liveness::Exited
923 );
924 identity.pid += 1;
925 assert_eq!(
926 invocation.liveness_with(|_| Ok(Some(identity.clone()))),
927 Liveness::Unknown
928 );
929 assert_eq!(invocation.liveness_with(|_| Ok(None)), Liveness::Exited);
930 for errno in [
931 libc::EPERM,
932 libc::EACCES,
933 libc::EIO,
934 libc::ENOENT,
935 libc::ESRCH,
936 ] {
937 assert_eq!(
938 invocation.liveness_with(|_| Err(std::io::Error::from_raw_os_error(errno).into())),
939 Liveness::Unknown
940 );
941 }
942 for pid in [0, u32::MAX] {
943 let invalid = Invocation {
944 frontend: ProcessKey {
945 pid,
946 started: (1, 0),
947 },
948 wrapper: None,
949 };
950 assert_eq!(
951 invalid.liveness_with(|_| panic!("invalid PID must not be queried")),
952 Liveness::Unknown
953 );
954 }
955 }
956
957 #[cfg(any(target_os = "linux", target_os = "macos"))]
958 #[test]
959 fn missing_metadata_does_not_imply_death() {
960 assert!(confirm_missing(std::process::id()).is_err());
961 assert!(confirm_missing(0).is_err());
962 assert!(confirm_missing(u32::MAX).is_err());
963 }
964
965 #[test]
966 fn requires_live_group_leader() {
967 let pane = process(10, 1, "/bin/zsh", &["zsh"]);
968 let mut cli = process(
969 20,
970 10,
971 "/home/alice/.local/share/claude/versions/2.1.1",
972 &["claude"],
973 );
974 assert!(decide(&pane, &[cli.clone()], 20, &[]).eligible);
975 cli.identity.runnable = false;
976 assert!(!decide(&pane, &[cli.clone()], 20, &[]).eligible);
977 cli.identity.runnable = true;
978 assert!(!decide(&pane, &[cli.clone()], 21, &[]).eligible);
979 assert!(decide(&cli, &[cli.clone()], 20, &[]).eligible);
980 assert!(!decide(&pane, &[], 20, &[]).eligible);
981 }
982
983 #[test]
984 fn nested_shell_chain_is_bounded_and_shell_only() {
985 let mut pane = process(10, 1, "/bin/zsh", &["zsh"]);
986 pane.identity.group = 10;
987 let cli = process(20, 15, "/home/alice/.opencode/bin/opencode", &["opencode"]);
988 let mut nested = process(15, 10, "/bin/bash", &["bash"]);
989 nested.identity.group = 15;
990 let chain = shell_chain(&pane, &cli, 20, |_| Ok(nested.clone())).unwrap();
991 assert_eq!(chain, vec![nested.clone(), pane.clone()]);
992 let mut middle = process(14, 10, "/bin/zsh", &["zsh"]);
993 middle.identity.group = 14;
994 let mut deeper = nested.clone();
995 deeper.identity.parent = 14;
996 let longer = shell_chain(&pane, &cli, 20, |pid| match pid {
997 15 => Ok(deeper.clone()),
998 14 => Ok(middle.clone()),
999 _ => panic!("must only read the parent chain"),
1000 })
1001 .unwrap();
1002 assert_eq!(longer, vec![deeper, middle, pane.clone()]);
1003 assert!(
1004 shell_chain(&cli, &cli, 20, |_| panic!("no ancestors for pane leader"))
1005 .unwrap()
1006 .is_empty()
1007 );
1008
1009 for exe in ["/usr/bin/nvim", "/usr/bin/ssh", "/tmp/bash"] {
1010 let mut invalid = nested.clone();
1011 invalid.executable = exe.into();
1012 assert!(shell_chain(&pane, &cli, 20, |_| Ok(invalid.clone())).is_err());
1013 }
1014 for field in [
1015 "tty",
1016 "foreground",
1017 "stopped",
1018 "cycle",
1019 "disconnected",
1020 "pid",
1021 ] {
1022 let mut invalid = nested.clone();
1023 match field {
1024 "tty" => invalid.identity.tty = 2,
1025 "foreground" => invalid.identity.foreground = 15,
1026 "stopped" => invalid.identity.runnable = false,
1027 "cycle" => invalid.identity.parent = 15,
1028 "disconnected" => invalid.identity.parent = 0,
1029 "pid" => invalid.identity.pid = 99,
1030 _ => unreachable!(),
1031 }
1032 assert!(
1033 shell_chain(&pane, &cli, 20, |_| Ok(invalid.clone())).is_err(),
1034 "{field}"
1035 );
1036 }
1037 assert!(shell_chain(&pane, &cli, 20, |_| bail!("ancestor disappeared")).is_err());
1038 let mut reads = 0;
1039 assert!(shell_chain(&pane, &cli, 20, |pid| {
1040 reads += 1;
1041 Ok(process(pid, pid + 100, "/bin/bash", &["bash"]))
1042 })
1043 .is_err());
1044 assert_eq!(reads, MAX_SHELLS);
1045
1046 let mut invalid_pane = pane.clone();
1047 invalid_pane.executable = "/usr/bin/nvim".into();
1048 assert!(shell_chain(&invalid_pane, &cli, 20, |_| Ok(nested.clone())).is_err());
1049 invalid_pane = pane.clone();
1050 invalid_pane.identity.runnable = false;
1051 assert!(shell_chain(&invalid_pane, &cli, 20, |_| Ok(nested.clone())).is_err());
1052
1053 let first = Snapshot {
1054 pane,
1055 members: vec![cli],
1056 shells: chain,
1057 };
1058 for field in ["started", "executable", "inode", "parent"] {
1059 let mut changed = first.clone();
1060 match field {
1061 "started" => changed.shells[0].identity.started.0 += 1,
1062 "executable" => changed.shells[0].executable = "/bin/zsh".into(),
1063 "inode" => changed.shells[0].executable_id.inode += 1,
1064 "parent" => changed.shells[0].identity.parent = 99,
1065 _ => unreachable!(),
1066 }
1067 assert_ne!(first, changed, "ancestor {field} must be revalidated");
1068 }
1069 }
1070
1071 #[test]
1072 fn foreground_shell_does_not_authorize_background_harness() {
1073 let mut pane = process(10, 1, "/bin/zsh", &["zsh"]);
1074 pane.identity.group = 10;
1075 pane.identity.foreground = 15;
1076 let mut shell = process(15, 10, "/bin/bash", &["bash"]);
1077 shell.identity.group = 15;
1078 shell.identity.foreground = 15;
1079 assert!(shell_chain(&pane, &shell, 15, |_| panic!("direct pane parent")).is_ok());
1081 assert!(!decide(&pane, &[shell], 15, &[]).eligible);
1082 }
1083
1084 #[test]
1085 fn only_codex_wrapper_native_pair_is_allowed() {
1086 let pane = process(10, 1, "/bin/bash", &["bash"]);
1087 let wrapper = process(
1088 20,
1089 10,
1090 "/usr/bin/node",
1091 &[
1092 "node",
1093 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
1094 "-p",
1095 "work",
1096 ],
1097 );
1098 let mut native = process(
1099 21,
1100 20,
1101 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1102 &["codex", "-p", "work"],
1103 );
1104 assert!(!decide(&pane, &[wrapper.clone()], 20, &[]).eligible);
1105 assert!(decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1106 native.identity.parent = 10;
1107 assert!(!decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1108 native.identity.parent = 20;
1109 native.argv.push("exec".into());
1110 assert!(!decide(&pane, &[wrapper.clone(), native], 20, &[]).eligible);
1111 let other = process(22, 20, "/bin/sh", &["sh"]);
1112 assert!(!decide(&pane, &[wrapper, other], 20, &[]).eligible);
1113 }
1114
1115 #[test]
1116 fn opencode_pair_requires_matching_interactive_arguments() {
1117 let pane = process(10, 1, "/bin/bash", &["bash"]);
1118 let wrapper = process(
1119 20,
1120 10,
1121 "/usr/bin/node",
1122 &[
1123 "node",
1124 "/usr/lib/node_modules/opencode-ai/bin/opencode",
1125 "attach",
1126 "http://localhost:4096",
1127 ],
1128 );
1129 let native = process(
1130 21,
1131 20,
1132 "/usr/lib/node_modules/opencode-linux-x64/bin/opencode",
1133 &["opencode", "attach", "http://localhost:4096"],
1134 );
1135 assert!(!decide(&pane, &[wrapper.clone()], 20, &[]).eligible);
1136 assert!(decide(&pane, &[wrapper.clone(), native.clone()], 20, &[]).eligible);
1137 let extra = process(22, 20, "/bin/sh", &["sh"]);
1138 assert!(!decide(&pane, &[wrapper, native, extra], 20, &[]).eligible);
1139 }
1140
1141 #[test]
1142 fn helpers_need_live_bounded_ancestry_and_nonterminal_input() {
1143 let pane = process(10, 1, "/bin/bash", &[]);
1144 let leader = process(20, 10, "/home/alice/.opencode/bin/opencode", &["opencode"]);
1145 let mut members = vec![leader];
1146 for pid in 21..20 + MAX_MEMBERS as u32 {
1147 let mut helper = process(pid, pid - 1, "/usr/bin/node", &[]);
1148 helper.descriptors = Some(Descriptors {
1149 stdin: [Input::Pipe, Input::Socket, Input::Null][pid as usize % 3],
1150 extra_terminal: false,
1151 });
1152 members.push(helper);
1153 }
1154 assert!(decide(&pane, &members, 20, &[]).eligible);
1155 for field in [
1157 "stdin",
1158 "extra",
1159 "missing",
1160 "unrelated",
1161 "self-cycle",
1162 "cycle",
1163 "stopped",
1164 "unreadable",
1165 ] {
1166 let mut invalid = members.clone();
1167 match field {
1168 "stdin" => invalid[1].descriptors.as_mut().unwrap().stdin = Input::Other,
1169 "extra" => invalid[1].descriptors.as_mut().unwrap().extra_terminal = true,
1170 "missing" => invalid[1].identity.parent = 999,
1171 "unrelated" => invalid[1].identity.parent = 10,
1172 "self-cycle" => invalid[1].identity.parent = 21,
1173 "cycle" => invalid[1].identity.parent = 22,
1174 "stopped" => invalid[1].identity.runnable = false,
1175 "unreadable" => invalid[1].descriptors = None,
1176 _ => unreachable!(),
1177 }
1178 assert!(!decide(&pane, &invalid, 20, &[]).eligible, "{field}");
1179 }
1180 let mut extra = members.last().unwrap().clone();
1181 extra.identity.pid += 1;
1182 members.push(extra);
1183 assert!(!decide(&pane, &members, 20, &[]).eligible);
1184 }
1185
1186 #[test]
1187 fn wrapper_helpers_must_be_rooted_at_the_verified_pair() {
1188 let pane = process(10, 1, "/bin/bash", &[]);
1189 let wrapper = process(
1190 20,
1191 10,
1192 "/usr/bin/node",
1193 &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js"],
1194 );
1195 let native = process(
1196 21,
1197 20,
1198 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1199 &["codex"],
1200 );
1201 let mut helper = process(22, 21, "/usr/bin/node", &[]);
1202 helper.descriptors = Some(Descriptors {
1203 stdin: Input::Pipe,
1204 extra_terminal: false,
1205 });
1206 let mut members = vec![wrapper, native, helper];
1207 assert!(decide(&pane, &members, 20, &[]).eligible);
1208 members[2].identity.parent = 20;
1209 assert!(decide(&pane, &members, 20, &[]).eligible);
1210 members[1].argv.push("exec".into());
1211 assert!(!decide(&pane, &members, 20, &[]).eligible);
1212 members[1].argv.pop();
1213 let mut duplicate = members[1].clone();
1214 duplicate.identity.pid = 23;
1215 members.push(duplicate);
1216 assert!(!decide(&pane, &members, 20, &[]).eligible);
1217 }
1218
1219 #[test]
1220 fn revalidation_retains_helper_identity_executable_and_relevant_fd_evidence() {
1221 let mut helper = process(21, 20, "/usr/bin/node", &[]);
1222 helper.descriptors = Some(Descriptors {
1223 stdin: Input::Pipe,
1224 extra_terminal: false,
1225 });
1226 for field in [
1227 "started",
1228 "parent",
1229 "executable",
1230 "inode",
1231 "mtime",
1232 "stdin",
1233 "terminal",
1234 ] {
1235 let mut changed = helper.clone();
1236 match field {
1237 "started" => changed.identity.started.0 += 1,
1238 "parent" => changed.identity.parent += 1,
1239 "executable" => changed.executable = "/usr/bin/editor".into(),
1240 "inode" => changed.executable_id.inode += 1,
1241 "mtime" => changed.executable_id.modified.0 += 1,
1242 "stdin" => changed.descriptors.as_mut().unwrap().stdin = Input::Socket,
1243 "terminal" => changed.descriptors.as_mut().unwrap().extra_terminal = true,
1244 _ => unreachable!(),
1245 }
1246 assert_ne!(helper, changed, "{field}");
1247 }
1248 assert!(within_budget(Instant::now() - BUDGET).is_err());
1249 }
1250}