Skip to main content

agent_float_term/inspect/
mod.rs

1//! Best-effort foreground-job inspection, not proof of native TUI keyboard ownership.
2//! All failures are conservative; callers should pass the key through on errors.
3//!
4//! Supported ancestry is a bounded chain of recognized shells on the pane's TTY,
5//! not an arbitrary descendant tree. Missing/exited group leaders, intervening
6//! editors/SSH, nested PTYs, and stopped ancestors are rejected. Same-group helpers
7//! must descend from a verified frontend and have nonterminal stdin and no extra TTY FDs.
8//! Frontend launches with unsupported flags/layouts or overwritten titles/argv that
9//! no longer preserve a recognizable command are unsupported; original argv cannot
10//! be reconstructed. Revalidation is best effort, not an atomic keyboard-ownership check.
11
12use 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    /// Present only for an eligible job whose two snapshots matched.
48    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/// Identity of a validated frontend launch, independent of helpers and job control.
62/// Kernel start times are platform-specific; tokens are local to this host/boot.
63#[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    /// Every tracked process still exists, including stopped processes.
87    Alive,
88    /// At least one tracked process exited, is a zombie, or its PID was reused.
89    Exited,
90    /// Metadata could not establish whether the invocation still exists.
91    Unknown,
92}
93
94impl Invocation {
95    /// Check only PID/start-time/state metadata, not foreground eligibility.
96    /// This is a best-effort observation, not an atomic guarantee of continued life.
97    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            // Deserialized tokens must never turn PID 0/negative into group probes.
112            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// Missing procfs metadata can mean hidepid permissions or an unavailable mount,
134// not death. A signal-0 probe can confirm absence, but cannot confirm identity.
135#[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    // SAFETY: a positive, validated PID and signal 0 only query existence/permission.
139    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// Exclude access times and other activity-dependent metadata from revalidation.
168#[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// Only retain evidence relevant to keyboard ownership, not pipe bytes, offsets,
199// socket state, or the identities/count of unrelated event-loop descriptors.
200#[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
245/// Inspect the pane's actual foreground job, without hooks or process environments.
246/// Unsupported/ambiguous jobs return a negative decision. OS/race/resource failures
247/// return an error, which the caller must treat as ineligible as well.
248pub 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    // A tmux run-shell helper need not have this controlling terminal. In particular,
263    // Linux may reject tcgetpgrp on the slave in that context; use kernel process metadata.
264    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        // Waiting ancestor shells normally have their own (background) process groups.
362        // Their terminal's foreground group, not their own group, must match the job.
363        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    // snapshot has already validated the entire shell-only chain to the pane.
597    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                // Positive evidence of either process ending wins over an unreadable peer.
879                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        // A background harness in group 20 is not among the foreground members.
1080        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        // Every intermediate process must be safe, not just the final helper.
1156        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}