Skip to main content

handshake_core/
harness.rs

1//! Built-in harness definitions and finding their binaries.
2
3use std::collections::HashSet;
4use std::ffi::{OsStr, OsString};
5use std::io::{Read, Seek};
6use std::os::unix::ffi::OsStrExt;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11use std::thread::{self, JoinHandle};
12use std::time::{Duration, Instant};
13
14use thiserror::Error;
15use tracing::debug;
16
17use crate::process::CommandChild;
18
19pub(crate) mod antigravity;
20pub(crate) mod claude;
21pub(crate) mod codex;
22pub(crate) mod history;
23pub(crate) mod instructions;
24pub(crate) mod kiro;
25pub(crate) mod launch;
26pub(crate) mod models;
27pub(crate) mod opencode;
28pub(crate) mod pi;
29mod prompt_context;
30pub(crate) mod resume;
31pub(crate) mod steps;
32
33/// How long to wait for the login shell to report its `PATH`.
34const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(5);
35const MAX_LOGIN_SHELL_OUTPUT: u64 = 64 * 1024;
36const PATH_START: &[u8] = b"__HANDSHAKE_PATH__";
37const PATH_END: &[u8] = b"__HANDSHAKE_END__";
38
39#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub enum HarnessId {
42    Codex,
43    ClaudeCode,
44    Pi,
45    Antigravity,
46    Omp,
47    Opencode,
48    Kiro,
49}
50
51/// How to launch one harness. Adding a harness means adding a definition here.
52#[derive(Debug)]
53pub(crate) struct HarnessDefinition {
54    pub name: &'static str,
55    pub binary: &'static str,
56    /// Whether the harness reads a prompt that starts with `@` as a file path.
57    pub reads_at_prefix_as_file: bool,
58    /// A flag for an initial prompt that keeps the harness interactive, when it needs one.
59    pub prompt_option: Option<&'static str>,
60    /// An explicit interactive subcommand, when positional prompts could select another command.
61    pub subcommand: Option<&'static str>,
62}
63
64static CODEX: HarnessDefinition = HarnessDefinition {
65    name: "Codex",
66    binary: "codex",
67    reads_at_prefix_as_file: false,
68    prompt_option: None,
69    subcommand: None,
70};
71static CLAUDE_CODE: HarnessDefinition = HarnessDefinition {
72    name: "Claude Code",
73    binary: "claude",
74    reads_at_prefix_as_file: false,
75    prompt_option: None,
76    subcommand: None,
77};
78static PI: HarnessDefinition = HarnessDefinition {
79    name: "pi",
80    binary: "pi",
81    reads_at_prefix_as_file: true,
82    prompt_option: None,
83    subcommand: None,
84};
85static ANTIGRAVITY: HarnessDefinition = HarnessDefinition {
86    name: "Antigravity",
87    binary: "agy",
88    reads_at_prefix_as_file: false,
89    prompt_option: Some("--prompt-interactive"),
90    subcommand: None,
91};
92static OMP: HarnessDefinition = HarnessDefinition {
93    name: "OMP",
94    binary: "omp",
95    // OMP treats everything after `--` as literal text, including `@` prefixes.
96    reads_at_prefix_as_file: false,
97    prompt_option: None,
98    subcommand: Some("launch"),
99};
100static OPENCODE: HarnessDefinition = HarnessDefinition {
101    name: "OpenCode",
102    binary: "opencode",
103    reads_at_prefix_as_file: false,
104    prompt_option: Some("--prompt"),
105    subcommand: Some("mini"),
106};
107
108static KIRO: HarnessDefinition = HarnessDefinition {
109    name: "Kiro CLI",
110    binary: "kiro-cli",
111    reads_at_prefix_as_file: true,
112    prompt_option: None,
113    subcommand: Some("chat"),
114};
115
116impl HarnessId {
117    pub(crate) fn definition(self) -> &'static HarnessDefinition {
118        match self {
119            Self::Codex => &CODEX,
120            Self::ClaudeCode => &CLAUDE_CODE,
121            Self::Pi => &PI,
122            Self::Antigravity => &ANTIGRAVITY,
123            Self::Omp => &OMP,
124            Self::Opencode => &OPENCODE,
125            Self::Kiro => &KIRO,
126        }
127    }
128}
129
130impl HarnessDefinition {
131    /// An interactive prompt, protected from being read as a flag even with a leading dash.
132    pub(crate) fn arguments(&self, prompt: &str) -> Vec<OsString> {
133        // Kiro V3 treats `--` as an unknown option and consumes the next argument.
134        // A leading space preserves literal prompts, including flags and `chat`.
135        if self.binary == KIRO.binary {
136            return vec![OsString::from(format!(" {prompt}"))];
137        }
138        let prompt = if self.reads_at_prefix_as_file && prompt.starts_with('@') {
139            format!(" {prompt}")
140        } else {
141            prompt.to_owned()
142        };
143        match self.prompt_option {
144            Some(option) => vec![OsString::from(format!("{option}={prompt}"))],
145            None => vec![OsString::from("--"), OsString::from(prompt)],
146        }
147    }
148}
149
150/// A harness binary and the `PATH` it should run with.
151#[derive(Debug, Eq, PartialEq)]
152pub(crate) struct LocatedHarness {
153    pub program: PathBuf,
154    pub path: OsString,
155}
156
157#[derive(Debug, Error)]
158pub enum HarnessError {
159    #[error(
160        "{name} wasn't found on your PATH. Install it or add `{binary}` to your PATH, then try again."
161    )]
162    NotFound {
163        name: &'static str,
164        binary: &'static str,
165    },
166}
167
168/// Finds `definition`'s binary on the login shell's `PATH`, then on `process_path`.
169///
170/// A GUI app inherits a minimal `PATH`, so the login shell's is searched first.
171pub(crate) fn locate_in(
172    definition: &HarnessDefinition,
173    login_path: Option<&OsStr>,
174    process_path: Option<&OsStr>,
175) -> Result<LocatedHarness, HarnessError> {
176    let directories = unique_directories(login_path.into_iter().chain(process_path));
177    let program = directories
178        .iter()
179        .map(|directory| directory.join(definition.binary))
180        .find(|candidate| is_executable(candidate))
181        .ok_or(HarnessError::NotFound {
182            name: definition.name,
183            binary: definition.binary,
184        })?;
185    let path = std::env::join_paths(&directories).unwrap_or_default();
186    Ok(LocatedHarness { program, path })
187}
188
189fn unique_directories<'a>(paths: impl Iterator<Item = &'a OsStr>) -> Vec<PathBuf> {
190    let mut seen = HashSet::new();
191    paths
192        .flat_map(|path| std::env::split_paths(path))
193        .filter(|directory| !directory.as_os_str().is_empty() && seen.insert(directory.clone()))
194        .collect()
195}
196
197fn is_executable(path: &Path) -> bool {
198    use std::os::unix::fs::PermissionsExt;
199    std::fs::metadata(path)
200        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
201}
202
203/// The login shell's `PATH`, looked up once in the background so starting an agent never waits on
204/// the user's shell startup files.
205pub(crate) struct LoginPath {
206    state: Mutex<LoginPathState>,
207    cancelled: Arc<AtomicBool>,
208}
209
210enum LoginPathState {
211    Pending(JoinHandle<Option<OsString>>),
212    Ready(Option<OsString>),
213}
214
215impl LoginPath {
216    pub(crate) fn spawn(shell: PathBuf) -> Self {
217        let cancelled = Arc::new(AtomicBool::new(false));
218        let flag = Arc::clone(&cancelled);
219        match crate::blocking_worker::spawn("login-path".into(), move || {
220            login_shell_path(&shell, &flag)
221        }) {
222            Ok(handle) => Self {
223                state: Mutex::new(LoginPathState::Pending(handle)),
224                cancelled,
225            },
226            Err(error) => {
227                debug!(%error, "failed to start the login PATH lookup");
228                Self::ready(None)
229            }
230        }
231    }
232
233    pub(crate) fn ready(path: Option<OsString>) -> Self {
234        Self {
235            state: Mutex::new(LoginPathState::Ready(path)),
236            cancelled: Arc::new(AtomicBool::new(false)),
237        }
238    }
239
240    pub(crate) fn get(&self) -> Option<OsString> {
241        let mut state = self
242            .state
243            .lock()
244            .unwrap_or_else(std::sync::PoisonError::into_inner);
245        if let LoginPathState::Pending(_) = &*state {
246            let LoginPathState::Pending(handle) =
247                std::mem::replace(&mut *state, LoginPathState::Ready(None))
248            else {
249                unreachable!("state was just checked to be pending");
250            };
251            *state = LoginPathState::Ready(handle.join().unwrap_or(None));
252        }
253        if self.cancelled.load(Ordering::Relaxed) {
254            return None;
255        }
256        match &*state {
257            LoginPathState::Ready(path) => path.clone(),
258            LoginPathState::Pending(_) => None,
259        }
260    }
261
262    pub(crate) fn cancel(&self) {
263        self.cancelled.store(true, Ordering::Relaxed);
264    }
265
266    pub(crate) fn shutdown(&self) {
267        self.cancel();
268        // `get` joins even a cancelled lookup, so every caller waits for process cleanup.
269        let _ = self.get();
270    }
271}
272
273impl Drop for LoginPath {
274    fn drop(&mut self) {
275        self.shutdown();
276    }
277}
278
279/// Asks an interactive login shell for its `PATH`, where version managers such as nvm and fnm are
280/// usually set up. Only `PATH` is read, between markers so startup banners can't be mistaken for it.
281fn login_shell_path(shell: &Path, cancelled: &AtomicBool) -> Option<OsString> {
282    if cancelled.load(Ordering::Relaxed) {
283        return None;
284    }
285    // A file avoids blocking on a full pipe while the shell is being supervised.
286    let mut output = tempfile::tempfile().ok()?;
287    let mut command = Command::new(shell);
288    command
289        .args(["-i", "-l", "-c"])
290        .arg("printf '\\n__HANDSHAKE_PATH__%s__HANDSHAKE_END__\\n' \"$(printenv PATH)\"")
291        .stdin(Stdio::null())
292        .stdout(output.try_clone().ok()?)
293        .stderr(Stdio::null());
294    let mut child = CommandChild::spawn(&mut command)
295        .map_err(|error| debug!(%error, "failed to start the login shell for PATH lookup"))
296        .ok()?;
297    let deadline = Instant::now() + LOGIN_SHELL_TIMEOUT;
298    loop {
299        if cancelled.load(Ordering::Relaxed) {
300            return None;
301        }
302        if output.metadata().ok()?.len() > MAX_LOGIN_SHELL_OUTPUT {
303            return None;
304        }
305        if child.has_exited().ok()? {
306            // Stop background jobs before reaping the shell and releasing its process identity.
307            child.finish().ok()?;
308            break;
309        }
310        if Instant::now() >= deadline {
311            debug!("login shell PATH lookup timed out");
312            return None;
313        }
314        thread::sleep(Duration::from_millis(5));
315    }
316    output.rewind().ok()?;
317    let mut bytes = Vec::new();
318    output
319        .take(MAX_LOGIN_SHELL_OUTPUT)
320        .read_to_end(&mut bytes)
321        .ok()?;
322    parse_path(&bytes)
323}
324
325fn parse_path(output: &[u8]) -> Option<OsString> {
326    let start = find(output, PATH_START)? + PATH_START.len();
327    let end = start + find(&output[start..], PATH_END)?;
328    let path = &output[start..end];
329    (!path.is_empty()).then(|| OsStr::from_bytes(path).to_owned())
330}
331
332fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
333    haystack
334        .windows(needle.len())
335        .position(|window| window == needle)
336}
337
338#[cfg(test)]
339mod tests {
340    use std::os::unix::fs::PermissionsExt;
341
342    use super::*;
343    use crate::process::tests::FixtureProcess;
344
345    #[test]
346    fn prompt_follows_a_double_dash() {
347        let arguments = HarnessId::ClaudeCode.definition().arguments("-fix it");
348        assert_eq!(arguments, ["--", "-fix it"].map(OsString::from));
349    }
350
351    #[test]
352    fn kiro_passes_literal_initial_prompts_without_a_double_dash() {
353        for prompt in ["-fix it", "@README.md", "chat", "line one\nline two"] {
354            assert_eq!(
355                KIRO.arguments(prompt),
356                [OsString::from(format!(" {prompt}"))]
357            );
358        }
359    }
360
361    #[test]
362    fn pi_keeps_an_at_prompt_from_being_read_as_a_file() {
363        let pi = HarnessId::Pi.definition().arguments("@README.md summarize");
364        assert_eq!(pi, ["--", " @README.md summarize"].map(OsString::from));
365        let claude = HarnessId::ClaudeCode.definition().arguments("@README.md");
366        assert_eq!(claude, ["--", "@README.md"].map(OsString::from));
367    }
368
369    #[test]
370    fn antigravity_keeps_initial_prompts_interactive_and_literal() {
371        let definition = HarnessId::Antigravity.definition();
372        assert_eq!(definition.binary, "agy");
373        for prompt in ["-fix it", "@README.md summarize", "first line\nsecond line"] {
374            assert_eq!(
375                definition.arguments(prompt),
376                [OsString::from(format!("--prompt-interactive={prompt}"))]
377            );
378        }
379    }
380
381    #[test]
382    fn omp_treats_flag_file_and_command_shaped_prompts_as_literal_text() {
383        let definition = HarnessId::Omp.definition();
384        assert_eq!(definition.binary, "omp");
385        assert_eq!(definition.subcommand, Some("launch"));
386        for prompt in ["-fix it", "@README.md", "models", "line one\nline two"] {
387            assert_eq!(
388                definition.arguments(prompt),
389                ["--", prompt].map(OsString::from)
390            );
391        }
392    }
393
394    #[test]
395    fn opencode_keeps_prompts_out_of_its_directory_and_command_arguments() {
396        let definition = HarnessId::Opencode.definition();
397        assert_eq!(definition.binary, "opencode");
398        assert_eq!(definition.subcommand, Some("mini"));
399        for prompt in ["-fix it", "@README.md", "models", "line one\nline two"] {
400            assert_eq!(
401                definition.arguments(prompt),
402                [OsString::from(format!("--prompt={prompt}"))]
403            );
404        }
405    }
406
407    #[test]
408    fn parses_path_between_markers_and_ignores_banners() {
409        assert_eq!(
410            parse_path(b"PATH=/wrong\nbanner\n__HANDSHAKE_PATH__/a:/b__HANDSHAKE_END__\n"),
411            Some(OsString::from("/a:/b"))
412        );
413        assert_eq!(parse_path(b"PATH=/wrong\n"), None);
414        assert_eq!(parse_path(b"__HANDSHAKE_PATH____HANDSHAKE_END__"), None);
415    }
416
417    fn fake_shell(directory: &Path, body: &str) -> PathBuf {
418        let shell = directory.join("shell");
419        std::fs::write(&shell, format!("#!/bin/sh\n{body}\n")).unwrap();
420        std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
421        shell
422    }
423
424    #[test]
425    fn login_path_comes_from_an_interactive_login_shell() {
426        let directory = tempfile::tempdir().unwrap();
427        // Like a zsh that only sets up its version manager in the interactive rc file.
428        let shell = fake_shell(
429            directory.path(),
430            "echo banner PATH=/wrong\n[ \"$1 $2\" = '-i -l' ] && printf '\\n__HANDSHAKE_PATH__/interactive__HANDSHAKE_END__\\n'",
431        );
432
433        assert_eq!(
434            LoginPath::spawn(shell).get(),
435            Some(OsString::from("/interactive"))
436        );
437    }
438
439    #[test]
440    fn a_background_job_holding_the_output_does_not_delay_the_lookup() {
441        let directory = tempfile::tempdir().unwrap();
442        let pid_path = directory.path().join("child.pid");
443        let shell = fake_shell(
444            directory.path(),
445            &format!(
446                "sleep 60 & echo $! > '{}'; printf '\\n__HANDSHAKE_PATH__/fast__HANDSHAKE_END__\\n'",
447                pid_path.display()
448            ),
449        );
450
451        let started = Instant::now();
452        let path = login_shell_path(&shell, &AtomicBool::new(false));
453
454        assert_eq!(path, Some(OsString::from("/fast")));
455        assert!(started.elapsed() < Duration::from_secs(4));
456        FixtureProcess::read(&pid_path).assert_stopped();
457    }
458
459    #[test]
460    fn dropping_an_unused_login_path_stops_and_joins_its_shell() {
461        let directory = tempfile::tempdir().unwrap();
462        let pid_path = directory.path().join("shell.pid");
463        let shell = fake_shell(
464            directory.path(),
465            &format!("echo $$ > '{}'; exec sleep 60", pid_path.display()),
466        );
467        let path = LoginPath::spawn(shell);
468        let shell = FixtureProcess::read(&pid_path);
469        let started = Instant::now();
470        drop(path);
471        assert!(started.elapsed() < Duration::from_secs(2));
472        assert!(
473            !shell.is_running(),
474            "shutdown reaps the login shell before returning"
475        );
476    }
477
478    #[test]
479    fn locates_an_executable_and_reports_a_missing_one() {
480        let directory = tempfile::tempdir().expect("a directory should be available");
481        let binary = directory.path().join("handshake-test-harness");
482        std::fs::write(&binary, "#!/bin/sh\n").expect("binary should be written");
483        std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
484            .expect("permissions should be set");
485        let found = HarnessDefinition {
486            name: "Test",
487            binary: "handshake-test-harness",
488            reads_at_prefix_as_file: false,
489            prompt_option: None,
490            subcommand: None,
491        };
492        let missing = HarnessDefinition {
493            binary: "handshake-no-such-harness",
494            ..found
495        };
496        let path = Some(directory.path().as_os_str());
497
498        let located = locate_in(&found, None, path).expect("binary is on PATH");
499        let error = locate_in(&missing, None, path).expect_err("a missing binary is not found");
500
501        assert_eq!(located.program, binary);
502        assert!(error.to_string().contains("`handshake-no-such-harness`"));
503    }
504}