Skip to main content

opencode_ctl/launcher/
posix.rs

1//! The POSIX tab wrapper, the shell expressions it embeds, and the terminal walk and tab commands.
2
3use std::path::Path;
4
5use super::windows::{wt_directory_arg, WindowsShell};
6use super::WrapperInput;
7
8/// The boot id as the wrapper writes it (spec Pairing, lines 322-331). POSIX `sh` text valid under
9/// dash; embedded as `$(<EXPR>)` by `wrapper_script_posix`.
10#[cfg(target_os = "linux")]
11pub const BOOT_ID_EXPR_POSIX: &str = "cat /proc/sys/kernel/random/boot_id";
12/// Field 22 of `/proc/$$/stat`: the 20th token after the last `)` (`$$` inside `$(...)` is the
13/// shell's pid; measured under dash).
14#[cfg(target_os = "linux")]
15pub const STARTTIME_EXPR_POSIX: &str = "s=$(cat /proc/$$/stat); s=${s##*) }; set -- $s; echo ${20}";
16
17/// `kern.boottime`'s `sec = N` field. `{ sec = N, usec = M } ...`: the `[^u]` keeps the greedy `.*`
18/// from backtracking onto `usec = M` (that read `M`, and `0` on a round second).
19#[cfg(target_os = "macos")]
20pub const BOOT_ID_EXPR_POSIX: &str =
21    r#"sysctl -n kern.boottime | sed 's/.*[^u]sec = \([0-9]*\).*/\1/'"#;
22/// `ps -o lstart=` as epoch seconds, both sides forced to UTC. UNVERIFIED here: the `date -j`
23/// format string is measured on a Mac in Task 25 Step 4.
24#[cfg(target_os = "macos")]
25pub const STARTTIME_EXPR_POSIX: &str =
26    r#"TZ=UTC ps -o lstart= -p $$ | { read l; TZ=UTC date -j -f '%a %b %e %T %Y' "$l" +%s; }"#;
27
28/// Every other target (Windows included, because the CI matrix builds it and
29/// `wrapper_script_posix` is pure text) carries the Linux text so the text tests compile there.
30#[cfg(not(any(target_os = "linux", target_os = "macos")))]
31pub const BOOT_ID_EXPR_POSIX: &str = "cat /proc/sys/kernel/random/boot_id";
32#[cfg(not(any(target_os = "linux", target_os = "macos")))]
33pub const STARTTIME_EXPR_POSIX: &str = "s=$(cat /proc/$$/stat); s=${s##*) }; set -- $s; echo ${20}";
34
35/// Single-quote a value for POSIX `sh`: every `'` becomes `'\''`.
36pub fn quote_sh(v: &str) -> String {
37    format!("'{}'", v.replace('\'', r"'\''"))
38}
39
40/// The bash tab wrapper. It writes `tui-pid` (`$$:<boot_id>:<starttime>`, no newline) at its start and
41/// `tui-exited` (epoch ms) from its EXIT trap, both under a `umask 077` subshell so the sentinels
42/// are 0600 without changing the umask `opencode` runs under.
43pub fn wrapper_script_posix(input: &WrapperInput) -> String {
44    let q = |p: &Path| quote_sh(&p.to_string_lossy());
45    let pair_file = |suffix: &str| {
46        input.home.join(format!(
47            "pair.{}.g{}.{suffix}",
48            input.pair_id, input.generation
49        ))
50    };
51    let (boot, start) = if input.boot_id_expr {
52        (
53            format!("$({BOOT_ID_EXPR_POSIX})"),
54            format!("$({STARTTIME_EXPR_POSIX})"),
55        )
56    } else {
57        ("BOOT".to_string(), "START".to_string())
58    };
59    let mut s = String::new();
60    s.push_str("#!/usr/bin/env bash\n");
61    s.push_str(&format!(
62        "# opencode-ctl tab wrapper: pair {} generation {}\n",
63        input.pair_id, input.generation
64    ));
65    s.push_str(&format!(
66        "on_exit() {{ ( umask 077; printf '%s' \"$(date +%s)000\" > {} ); }}\n",
67        q(&pair_file("tui-exited"))
68    ));
69    s.push_str("trap on_exit EXIT\n");
70    s.push_str(&format!("boot={boot}\n"));
71    s.push_str(&format!("start={start}\n"));
72    s.push_str(&format!(
73        "( umask 077; printf '%s' \"$$:$boot:$start\" > {} )\n",
74        q(&pair_file("tui-pid"))
75    ));
76    if let (Some(_), Some(pw)) = (input.server_url, input.server_password) {
77        s.push_str(&format!(
78            "export OPENCODE_SERVER_PASSWORD={}\n",
79            quote_sh(pw)
80        ));
81        s.push_str("unset OPENCODE_PASSWORD\n");
82    }
83    s.push_str(&format!(
84        "{} {} -s {}",
85        q(input.opencode_bin),
86        q(input.directory),
87        quote_sh(input.session_id)
88    ));
89    if let Some(url) = input.server_url {
90        s.push_str(&format!(" --server {}", quote_sh(url)));
91    }
92    s.push('\n');
93    s
94}
95
96// ------------------------------------------------------------------ terminals
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Terminal {
100    Mate,
101    Gnome,
102    Xfce,
103    Konsole,
104    Wt,
105}
106
107impl Terminal {
108    pub fn program(self) -> &'static str {
109        match self {
110            Terminal::Mate => "mate-terminal",
111            Terminal::Gnome => "gnome-terminal",
112            Terminal::Xfce => "xfce4-terminal",
113            Terminal::Konsole => "konsole",
114            Terminal::Wt => "wt",
115        }
116    }
117}
118
119/// The four POSIX terminals in the spec's PATH order.
120const PATH_ORDER: [Terminal; 4] = [
121    Terminal::Mate,
122    Terminal::Gnome,
123    Terminal::Xfce,
124    Terminal::Konsole,
125];
126
127fn terminal_for_process(name: &str) -> Option<Terminal> {
128    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
129    match base {
130        "mate-terminal" => Some(Terminal::Mate),
131        // The gnome client execs; the shell's ancestor is the server process.
132        "gnome-terminal" | "gnome-terminal-server" => Some(Terminal::Gnome),
133        "xfce4-terminal" => Some(Terminal::Xfce),
134        "konsole" => Some(Terminal::Konsole),
135        _ => None,
136    }
137}
138
139/// FIRST the terminal found by walking the parent process names (nearest first), and ONLY when no
140/// parent matches the first of the four on PATH, in the spec's order. Windows: `wt` when on PATH.
141pub fn select_terminal(
142    parent_chain: &[String],
143    path_has: &dyn Fn(&str) -> bool,
144    windows: bool,
145) -> Option<Terminal> {
146    if windows {
147        return path_has("wt").then_some(Terminal::Wt);
148    }
149    parent_chain
150        .iter()
151        .find_map(|n| terminal_for_process(n))
152        .or_else(|| PATH_ORDER.into_iter().find(|t| path_has(t.program())))
153}
154
155/// The tab command, program first (spec Launcher step 4). `mate-terminal` and `xfce4-terminal` split
156/// the ONE `-e` string with `g_shell_parse_argv`, so that element is the script path single-quoted
157/// with every `'` written as `'\''`; `gnome-terminal` and `konsole` take argv elements and get the
158/// raw path.
159pub fn tab_command(
160    t: Terminal,
161    script: &Path,
162    directory: &Path,
163    shell: WindowsShell,
164) -> Vec<String> {
165    let script = script.to_string_lossy().into_owned();
166    let argv = |parts: &[&str]| parts.iter().map(|p| p.to_string()).collect::<Vec<_>>();
167    match t {
168        Terminal::Mate | Terminal::Xfce => {
169            let mut v = argv(&[t.program(), "--tab", "-e"]);
170            v.push(quote_sh(&script));
171            v
172        }
173        Terminal::Gnome => {
174            let mut v = argv(&["gnome-terminal", "--tab", "--"]);
175            v.push(script);
176            v
177        }
178        Terminal::Konsole => {
179            let mut v = argv(&["konsole", "--new-tab", "-e"]);
180            v.push(script);
181            v
182        }
183        Terminal::Wt => {
184            let mut v = argv(&["wt", "-w", "0", "nt", "-d"]);
185            v.push(wt_directory_arg(&directory.to_string_lossy()));
186            v.extend(argv(&[
187                shell.program(),
188                "-NoProfile",
189                "-ExecutionPolicy",
190                "Bypass",
191                "-File",
192            ]));
193            v.push(script);
194            v
195        }
196    }
197}
198
199/// The names of this process's ancestors, nearest first (the parent's basename of
200/// `/proc/<pid>/cmdline`'s first element on Linux, `ps -o comm=` on macOS; empty elsewhere).
201pub fn system_parent_chain() -> Vec<String> {
202    use crate::liveness::{ProcessProbe, SystemProbe};
203    let mut chain = Vec::new();
204    let mut pid = SystemProbe.parent_pid(std::process::id());
205    while let Some(p) = pid.filter(|p| *p > 1 && chain.len() < 32) {
206        if let Some(name) = process_name(p) {
207            chain.push(name);
208        }
209        pid = SystemProbe.parent_pid(p);
210    }
211    chain
212}
213
214#[cfg(target_os = "linux")]
215fn process_name(pid: u32) -> Option<String> {
216    let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
217    let first = raw.split(|b| *b == 0).next().filter(|f| !f.is_empty())?;
218    let first = String::from_utf8_lossy(first).into_owned();
219    Some(first.rsplit('/').next().unwrap_or(&first).to_string())
220}
221
222#[cfg(target_os = "macos")]
223fn process_name(pid: u32) -> Option<String> {
224    let out = std::process::Command::new("ps")
225        .args(["-o", "comm=", "-p", &pid.to_string()])
226        .output()
227        .ok()?;
228    let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
229    (!name.is_empty()).then(|| name.rsplit('/').next().unwrap_or(&name).to_string())
230}
231
232#[cfg(not(any(target_os = "linux", target_os = "macos")))]
233fn process_name(_pid: u32) -> Option<String> {
234    None
235}