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/// Recognize a foreground Neovim group leader without reading argv or environments.
261/// Errors mean unknown ownership: navigation keys should remain in the float.
262pub 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    // A tmux run-shell helper need not have this controlling terminal. In particular,
320    // Linux may reject tcgetpgrp on the slave in that context; use kernel process metadata.
321    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        // Waiting ancestor shells normally have their own (background) process groups.
419        // Their terminal's foreground group, not their own group, must match the job.
420        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    // snapshot has already validated the entire shell-only chain to the pane.
654    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                // Positive evidence of either process ending wins over an unreadable peer.
936                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        // A background harness in group 20 is not among the foreground members.
1137        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        // Every intermediate process must be safe, not just the final helper.
1213        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}