Skip to main content

taskers_runtime/
shell.rs

1use std::{
2    collections::BTreeMap,
3    env, fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result};
8#[cfg(unix)]
9use libc::{self, passwd};
10#[cfg(unix)]
11use std::{ffi::CStr, os::unix::ffi::OsStringExt};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum ShellKind {
15    Bash,
16    Fish,
17    Zsh,
18    Other,
19}
20
21const INHERITED_TERMINAL_ENV_KEYS: &[&str] = &[
22    "TERM",
23    "TERMINFO",
24    "TERMINFO_DIRS",
25    "TERM_PROGRAM",
26    "TERM_PROGRAM_VERSION",
27    "COLORTERM",
28    "NO_COLOR",
29    "CLICOLOR",
30    "CLICOLOR_FORCE",
31    "KITTY_INSTALLATION_DIR",
32    "KITTY_LISTEN_ON",
33    "KITTY_PUBLIC_KEY",
34    "KITTY_WINDOW_ID",
35    "GHOSTTY_BIN_DIR",
36    "GHOSTTY_RESOURCES_DIR",
37    "GHOSTTY_SHELL_FEATURES",
38    "GHOSTTY_SHELL_INTEGRATION_XDG_DIR",
39];
40
41#[derive(Debug, Clone)]
42pub struct ShellIntegration {
43    root: PathBuf,
44    wrapper_path: PathBuf,
45    real_shell: PathBuf,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ShellLaunchSpec {
50    pub program: PathBuf,
51    pub args: Vec<String>,
52    pub env: BTreeMap<String, String>,
53}
54
55impl ShellLaunchSpec {
56    pub fn fallback() -> Self {
57        let program = default_shell_program();
58        let args = match shell_kind(&program) {
59            ShellKind::Fish => vec!["--interactive".into()],
60            ShellKind::Bash | ShellKind::Zsh => vec!["-i".into()],
61            ShellKind::Other => Vec::new(),
62        };
63        Self {
64            program,
65            args,
66            env: base_env(),
67        }
68    }
69
70    pub fn program_and_args(&self) -> Vec<String> {
71        let mut argv = Vec::with_capacity(self.args.len() + 1);
72        argv.push(self.program.display().to_string());
73        argv.extend(self.args.iter().cloned());
74        argv
75    }
76}
77
78impl ShellIntegration {
79    pub fn install(configured_shell: Option<&str>) -> Result<Self> {
80        let root = runtime_root();
81        let wrapper_path = root.join("taskers-shell-wrapper.sh");
82        let real_shell = resolve_shell_program(configured_shell)?;
83
84        install_runtime_assets(&root)?;
85        install_agent_shims(&root)?;
86
87        Ok(Self {
88            root,
89            wrapper_path,
90            real_shell,
91        })
92    }
93
94    pub fn launch_spec(&self) -> ShellLaunchSpec {
95        let profile = std::env::var("TASKERS_SHELL_PROFILE").unwrap_or_else(|_| "default".into());
96        let integration_disabled = std::env::var_os("TASKERS_DISABLE_SHELL_INTEGRATION").is_some();
97
98        match shell_kind(&self.real_shell) {
99            ShellKind::Bash if !integration_disabled => {
100                let mut env = self.base_env();
101                env.insert(
102                    "TASKERS_REAL_SHELL".into(),
103                    self.real_shell.display().to_string(),
104                );
105                env.insert("TASKERS_SHELL_PROFILE".into(), profile);
106                if let Some(value) = std::env::var_os("TASKERS_USER_BASHRC") {
107                    env.insert(
108                        "TASKERS_USER_BASHRC".into(),
109                        value.to_string_lossy().into_owned(),
110                    );
111                }
112
113                ShellLaunchSpec {
114                    program: self.wrapper_path.clone(),
115                    args: Vec::new(),
116                    env,
117                }
118            }
119            ShellKind::Bash => ShellLaunchSpec {
120                program: self.real_shell.clone(),
121                args: vec!["--noprofile".into(), "--norc".into(), "-i".into()],
122                env: self.base_env(),
123            },
124            ShellKind::Fish if !integration_disabled => {
125                let mut env = self.base_env();
126                env.insert(
127                    "TASKERS_REAL_SHELL".into(),
128                    self.real_shell.display().to_string(),
129                );
130
131                let mut args = Vec::new();
132                if profile == "clean" {
133                    args.push("--no-config".into());
134                }
135                args.push("--interactive".into());
136                args.push("--init-command".into());
137                args.push(fish_source_command());
138
139                ShellLaunchSpec {
140                    program: self.wrapper_path.clone(),
141                    args,
142                    env,
143                }
144            }
145            ShellKind::Fish => ShellLaunchSpec {
146                program: self.real_shell.clone(),
147                args: vec!["--no-config".into(), "--interactive".into()],
148                env: self.base_env(),
149            },
150            ShellKind::Zsh if !integration_disabled => {
151                let mut env = self.base_env();
152                env.insert(
153                    "TASKERS_REAL_SHELL".into(),
154                    self.real_shell.display().to_string(),
155                );
156                env.insert(
157                    "ZDOTDIR".into(),
158                    zsh_runtime_dir(&self.root).display().to_string(),
159                );
160                if let Some(value) = env::var_os("ZDOTDIR").or_else(|| env::var_os("HOME")) {
161                    env.insert(
162                        "TASKERS_USER_ZDOTDIR".into(),
163                        value.to_string_lossy().into_owned(),
164                    );
165                }
166                let args = if profile == "clean" {
167                    vec!["-d".into(), "-i".into()]
168                } else {
169                    vec!["-i".into()]
170                };
171
172                ShellLaunchSpec {
173                    program: self.wrapper_path.clone(),
174                    args,
175                    env,
176                }
177            }
178            ShellKind::Zsh => ShellLaunchSpec {
179                program: self.real_shell.clone(),
180                args: vec!["-d".into(), "-f".into(), "-i".into()],
181                env: self.base_env(),
182            },
183            ShellKind::Other => {
184                let mut env = self.base_env();
185                env.insert(
186                    "TASKERS_REAL_SHELL".into(),
187                    self.real_shell.display().to_string(),
188                );
189                ShellLaunchSpec {
190                    program: self.wrapper_path.clone(),
191                    args: Vec::new(),
192                    env,
193                }
194            }
195        }
196    }
197
198    pub fn root(&self) -> &Path {
199        &self.root
200    }
201}
202
203impl ShellIntegration {
204    fn base_env(&self) -> BTreeMap<String, String> {
205        let mut env = base_env();
206        env.insert(
207            "TASKERS_SHELL_INTEGRATION_DIR".into(),
208            self.root.display().to_string(),
209        );
210        if let Some(path) = resolve_taskersctl_path() {
211            env.insert("TASKERS_CTL_PATH".into(), path.display().to_string());
212        }
213        let shim_dir = self.root.join("bin");
214        env.insert("PATH".into(), prepend_path_entry(&shim_dir));
215        env
216    }
217}
218
219pub fn install_shell_integration(configured_shell: Option<&str>) -> Result<ShellIntegration> {
220    ShellIntegration::install(configured_shell)
221}
222
223pub fn scrub_inherited_terminal_env() {
224    for key in INHERITED_TERMINAL_ENV_KEYS {
225        unsafe {
226            env::remove_var(key);
227        }
228    }
229}
230
231pub fn default_shell_program() -> PathBuf {
232    login_shell_from_passwd()
233        .or_else(shell_from_env)
234        .unwrap_or_else(|| PathBuf::from("/bin/sh"))
235}
236
237pub fn validate_shell_program(configured_shell: Option<&str>) -> Result<Option<PathBuf>> {
238    configured_shell
239        .and_then(normalize_shell_override)
240        .map(|value| resolve_shell_override(&value))
241        .transpose()
242}
243
244fn base_env() -> BTreeMap<String, String> {
245    let mut env = BTreeMap::new();
246    env.insert("TASKERS_EMBEDDED".into(), "1".into());
247    env.insert("TERM_PROGRAM".into(), "taskers".into());
248    env
249}
250
251fn install_agent_shims(root: &Path) -> Result<()> {
252    let shim_dir = root.join("bin");
253    fs::create_dir_all(&shim_dir)
254        .with_context(|| format!("failed to create {}", shim_dir.display()))?;
255    for (name, target_path) in [
256        ("codex", root.join("taskers-agent-codex.sh")),
257        ("claude", root.join("taskers-agent-claude.sh")),
258        ("claude-code", root.join("taskers-agent-claude.sh")),
259        ("opencode", root.join("taskers-agent-proxy.sh")),
260        ("aider", root.join("taskers-agent-proxy.sh")),
261    ] {
262        let shim_path = shim_dir.join(name);
263        if shim_path.symlink_metadata().is_ok() {
264            fs::remove_file(&shim_path)
265                .with_context(|| format!("failed to replace {}", shim_path.display()))?;
266        }
267
268        #[cfg(unix)]
269        std::os::unix::fs::symlink(&target_path, &shim_path).with_context(|| {
270            format!(
271                "failed to symlink {} -> {}",
272                shim_path.display(),
273                target_path.display()
274            )
275        })?;
276
277        #[cfg(not(unix))]
278        fs::copy(&target_path, &shim_path).with_context(|| {
279            format!(
280                "failed to copy {} -> {}",
281                target_path.display(),
282                shim_path.display()
283            )
284        })?;
285    }
286
287    Ok(())
288}
289
290fn prepend_path_entry(entry: &Path) -> String {
291    let mut parts = vec![entry.display().to_string()];
292    if let Some(path) = env::var_os("PATH") {
293        parts.extend(
294            env::split_paths(&path)
295                .filter(|candidate| candidate != entry)
296                .map(|candidate| candidate.display().to_string()),
297        );
298    }
299    parts.join(":")
300}
301
302fn runtime_root() -> PathBuf {
303    taskers_paths::default_shell_runtime_dir()
304}
305
306fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> {
307    if let Some(parent) = path.parent() {
308        fs::create_dir_all(parent)
309            .with_context(|| format!("failed to create {}", parent.display()))?;
310    }
311
312    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
313
314    #[cfg(unix)]
315    if executable {
316        use std::os::unix::fs::PermissionsExt;
317
318        let mut permissions = fs::metadata(path)
319            .with_context(|| format!("failed to stat {}", path.display()))?
320            .permissions();
321        permissions.set_mode(0o755);
322        fs::set_permissions(path, permissions)
323            .with_context(|| format!("failed to chmod {}", path.display()))?;
324    }
325
326    Ok(())
327}
328
329fn resolve_taskersctl_path() -> Option<PathBuf> {
330    if let Some(path) = std::env::current_exe()
331        .ok()
332        .and_then(|path| path.parent().map(|parent| parent.join("taskersctl")))
333        .filter(|path| path.is_file())
334    {
335        return Some(path);
336    }
337
338    if let Some(path) = env::var_os("TASKERS_CTL_PATH")
339        .map(PathBuf::from)
340        .filter(|path| path.is_file())
341    {
342        return Some(path);
343    }
344
345    if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
346        for candidate in [
347            home.join(".cargo").join("bin").join("taskersctl"),
348            home.join(".local").join("bin").join("taskersctl"),
349        ] {
350            if candidate.is_file() {
351                return Some(candidate);
352            }
353        }
354    }
355
356    let path_var = env::var_os("PATH")?;
357    env::split_paths(&path_var)
358        .map(|entry| entry.join("taskersctl"))
359        .find(|candidate| candidate.is_file())
360}
361
362fn resolve_shell_program(configured_shell: Option<&str>) -> Result<PathBuf> {
363    if let Some(shell) = configured_shell.and_then(|value| normalize_shell_override(value)) {
364        return resolve_shell_override(&shell)
365            .with_context(|| format!("failed to resolve configured shell {shell}"));
366    }
367
368    Ok(default_shell_program())
369}
370
371fn shell_kind(path: &Path) -> ShellKind {
372    let name = path
373        .file_name()
374        .and_then(|value| value.to_str())
375        .unwrap_or_default()
376        .trim_start_matches('-');
377
378    match name {
379        "bash" => ShellKind::Bash,
380        "fish" => ShellKind::Fish,
381        "zsh" => ShellKind::Zsh,
382        _ => ShellKind::Other,
383    }
384}
385
386fn normalize_shell_override(value: &str) -> Option<String> {
387    let trimmed = value.trim();
388    if trimmed.is_empty() {
389        None
390    } else {
391        Some(trimmed.to_string())
392    }
393}
394
395fn resolve_shell_override(value: &str) -> Result<PathBuf> {
396    let expanded = expand_home_prefix(value);
397    let candidate = PathBuf::from(&expanded);
398    if expanded.contains('/') {
399        anyhow::ensure!(
400            candidate.is_file(),
401            "shell program {} does not exist",
402            candidate.display()
403        );
404        return Ok(candidate);
405    }
406
407    let path_var = env::var_os("PATH").unwrap_or_default();
408    let resolved = env::split_paths(&path_var)
409        .map(|entry| entry.join(&candidate))
410        .find(|entry| entry.is_file());
411    resolved.with_context(|| format!("shell program {value} was not found in PATH"))
412}
413
414fn expand_home_prefix(value: &str) -> String {
415    if value == "~" {
416        return env::var("HOME").unwrap_or_else(|_| value.to_string());
417    }
418
419    if let Some(suffix) = value.strip_prefix("~/") {
420        if let Some(home) = env::var_os("HOME") {
421            return PathBuf::from(home).join(suffix).display().to_string();
422        }
423    }
424
425    value.to_string()
426}
427
428fn shell_from_env() -> Option<PathBuf> {
429    env::var_os("SHELL")
430        .map(PathBuf::from)
431        .filter(|path| !path.as_os_str().is_empty())
432}
433
434#[cfg(unix)]
435fn login_shell_from_passwd() -> Option<PathBuf> {
436    let uid = unsafe { libc::geteuid() };
437    let mut pwd = std::mem::MaybeUninit::<passwd>::uninit();
438    let mut result = std::ptr::null_mut::<passwd>();
439    let mut buffer = vec![0u8; passwd_buffer_size()];
440
441    let status = unsafe {
442        libc::getpwuid_r(
443            uid,
444            pwd.as_mut_ptr(),
445            buffer.as_mut_ptr().cast(),
446            buffer.len(),
447            &mut result,
448        )
449    };
450    if status != 0 || result.is_null() {
451        return None;
452    }
453
454    let pwd = unsafe { pwd.assume_init() };
455    if pwd.pw_shell.is_null() {
456        return None;
457    }
458
459    let shell = unsafe { CStr::from_ptr(pwd.pw_shell) }.to_bytes().to_vec();
460    if shell.is_empty() {
461        return None;
462    }
463
464    Some(PathBuf::from(std::ffi::OsString::from_vec(shell)))
465}
466
467#[cfg(not(unix))]
468fn login_shell_from_passwd() -> Option<PathBuf> {
469    None
470}
471
472#[cfg(unix)]
473fn passwd_buffer_size() -> usize {
474    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
475    if size <= 0 { 4096 } else { size as usize }
476}
477
478#[cfg(not(unix))]
479fn passwd_buffer_size() -> usize {
480    4096
481}
482
483fn fish_source_command() -> String {
484    r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#.into()
485}
486
487fn zsh_runtime_dir(root: &Path) -> PathBuf {
488    root.join("zsh")
489}
490
491fn install_runtime_assets(root: &Path) -> Result<()> {
492    write_asset(
493        &root.join("taskers-shell-wrapper.sh"),
494        include_str!(concat!(
495            env!("CARGO_MANIFEST_DIR"),
496            "/assets/shell/taskers-shell-wrapper.sh"
497        )),
498        true,
499    )?;
500    write_asset(
501        &root.join("bash").join("taskers.bashrc"),
502        include_str!(concat!(
503            env!("CARGO_MANIFEST_DIR"),
504            "/assets/shell/bash/taskers.bashrc"
505        )),
506        false,
507    )?;
508    write_asset(
509        &root.join("taskers-hooks.bash"),
510        include_str!(concat!(
511            env!("CARGO_MANIFEST_DIR"),
512            "/assets/shell/taskers-hooks.bash"
513        )),
514        false,
515    )?;
516    write_asset(
517        &root.join("taskers-hooks.fish"),
518        include_str!(concat!(
519            env!("CARGO_MANIFEST_DIR"),
520            "/assets/shell/taskers-hooks.fish"
521        )),
522        false,
523    )?;
524    write_asset(
525        &zsh_runtime_dir(root).join(".zshenv"),
526        include_str!(concat!(
527            env!("CARGO_MANIFEST_DIR"),
528            "/assets/shell/zsh/.zshenv"
529        )),
530        false,
531    )?;
532    write_asset(
533        &zsh_runtime_dir(root).join(".zshrc"),
534        include_str!(concat!(
535            env!("CARGO_MANIFEST_DIR"),
536            "/assets/shell/zsh/.zshrc"
537        )),
538        false,
539    )?;
540    write_asset(
541        &zsh_runtime_dir(root).join(".zcompdump"),
542        include_str!(concat!(
543            env!("CARGO_MANIFEST_DIR"),
544            "/assets/shell/zsh/.zcompdump"
545        )),
546        false,
547    )?;
548    write_asset(
549        &root.join("taskers-codex-notify.sh"),
550        include_str!(concat!(
551            env!("CARGO_MANIFEST_DIR"),
552            "/assets/shell/taskers-codex-notify.sh"
553        )),
554        true,
555    )?;
556    write_asset(
557        &root.join("taskers-claude-hook.sh"),
558        include_str!(concat!(
559            env!("CARGO_MANIFEST_DIR"),
560            "/assets/shell/taskers-claude-hook.sh"
561        )),
562        true,
563    )?;
564    write_asset(
565        &root.join("taskers-agent-codex.sh"),
566        include_str!(concat!(
567            env!("CARGO_MANIFEST_DIR"),
568            "/assets/shell/taskers-agent-codex.sh"
569        )),
570        true,
571    )?;
572    write_asset(
573        &root.join("taskers-agent-claude.sh"),
574        include_str!(concat!(
575            env!("CARGO_MANIFEST_DIR"),
576            "/assets/shell/taskers-agent-claude.sh"
577        )),
578        true,
579    )?;
580    write_asset(
581        &root.join("taskers-agent-proxy.sh"),
582        include_str!(concat!(
583            env!("CARGO_MANIFEST_DIR"),
584            "/assets/shell/taskers-agent-proxy.sh"
585        )),
586        true,
587    )?;
588    Ok(())
589}
590
591#[cfg(test)]
592mod tests {
593    use std::{
594        fs,
595        path::PathBuf,
596        sync::Mutex,
597        time::{Duration, SystemTime},
598    };
599
600    #[cfg(unix)]
601    use std::os::unix::fs::PermissionsExt;
602
603    use super::{
604        INHERITED_TERMINAL_ENV_KEYS, ShellIntegration, expand_home_prefix, fish_source_command,
605        install_runtime_assets, normalize_shell_override, resolve_shell_override, zsh_runtime_dir,
606    };
607    use crate::{CommandSpec, PtySession};
608
609    static ENV_LOCK: Mutex<()> = Mutex::new(());
610
611    #[test]
612    fn shell_override_normalizes_blank_values() {
613        assert_eq!(normalize_shell_override(""), None);
614        assert_eq!(normalize_shell_override("   "), None);
615        assert_eq!(
616            normalize_shell_override(" /usr/bin/fish "),
617            Some("/usr/bin/fish".into())
618        );
619    }
620
621    #[test]
622    fn fish_source_command_uses_runtime_env_path() {
623        assert_eq!(
624            fish_source_command(),
625            r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#
626        );
627    }
628
629    #[test]
630    fn zsh_launch_spec_routes_through_runtime_zdotdir() {
631        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
632        let original_zdotdir = std::env::var_os("ZDOTDIR");
633        let original_home = std::env::var_os("HOME");
634        unsafe {
635            std::env::set_var("HOME", "/tmp/taskers-home");
636            std::env::set_var("ZDOTDIR", "/tmp/user-zdotdir");
637            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
638            std::env::remove_var("TASKERS_SHELL_PROFILE");
639        }
640
641        let integration = ShellIntegration {
642            root: PathBuf::from("/tmp/taskers-runtime"),
643            wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
644            real_shell: PathBuf::from("/usr/bin/zsh"),
645        };
646        let spec = integration.launch_spec();
647
648        assert_eq!(
649            spec.env.get("ZDOTDIR").map(String::as_str),
650            Some("/tmp/taskers-runtime/zsh")
651        );
652        assert_eq!(
653            spec.env.get("TASKERS_USER_ZDOTDIR").map(String::as_str),
654            Some("/tmp/user-zdotdir")
655        );
656        assert_eq!(spec.program, integration.wrapper_path);
657        assert_eq!(spec.args, vec!["-i"]);
658
659        unsafe {
660            match original_zdotdir {
661                Some(value) => std::env::set_var("ZDOTDIR", value),
662                None => std::env::remove_var("ZDOTDIR"),
663            }
664            match original_home {
665                Some(value) => std::env::set_var("HOME", value),
666                None => std::env::remove_var("HOME"),
667            }
668        }
669    }
670
671    #[test]
672    fn zsh_runtime_dir_is_nested_under_runtime_root() {
673        assert_eq!(
674            zsh_runtime_dir(&PathBuf::from("/tmp/taskers-runtime")),
675            PathBuf::from("/tmp/taskers-runtime/zsh")
676        );
677    }
678
679    #[test]
680    fn install_runtime_assets_writes_zsh_runtime_files() {
681        let root = unique_temp_dir("taskers-runtime-test");
682        install_runtime_assets(&root).expect("install runtime assets");
683
684        assert!(root.join("taskers-shell-wrapper.sh").is_file());
685        assert!(root.join("taskers-hooks.bash").is_file());
686        assert!(root.join("taskers-hooks.fish").is_file());
687        assert!(root.join("taskers-codex-notify.sh").is_file());
688        assert!(root.join("taskers-claude-hook.sh").is_file());
689        assert!(root.join("taskers-agent-codex.sh").is_file());
690        assert!(root.join("taskers-agent-claude.sh").is_file());
691        assert!(root.join("taskers-agent-proxy.sh").is_file());
692        assert!(zsh_runtime_dir(&root).join(".zshenv").is_file());
693        assert!(zsh_runtime_dir(&root).join(".zshrc").is_file());
694        assert!(zsh_runtime_dir(&root).join(".zcompdump").is_file());
695
696        fs::remove_dir_all(&root).expect("cleanup runtime assets");
697    }
698
699    #[test]
700    fn home_prefix_expansion_without_home_keeps_original_shape() {
701        let original = "~/bin/fish";
702        let expanded = expand_home_prefix(original);
703        if std::env::var_os("HOME").is_some() {
704            assert_ne!(expanded, original);
705        } else {
706            assert_eq!(expanded, original);
707        }
708    }
709
710    #[test]
711    fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() {
712        for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] {
713            assert!(
714                INHERITED_TERMINAL_ENV_KEYS.contains(&key),
715                "expected {key} to be scrubbed from inherited terminal env"
716            );
717        }
718    }
719
720    #[test]
721    fn shell_wrapper_exports_taskers_tty_name() {
722        let wrapper = include_str!(concat!(
723            env!("CARGO_MANIFEST_DIR"),
724            "/assets/shell/taskers-shell-wrapper.sh"
725        ));
726        assert!(
727            wrapper.contains("TASKERS_TTY_NAME"),
728            "expected wrapper to export TASKERS_TTY_NAME"
729        );
730    }
731
732    #[test]
733    fn shell_hooks_and_proxy_require_surface_tty_identity() {
734        let bash_hooks = include_str!(concat!(
735            env!("CARGO_MANIFEST_DIR"),
736            "/assets/shell/taskers-hooks.bash"
737        ));
738        let zsh_hooks = include_str!(concat!(
739            env!("CARGO_MANIFEST_DIR"),
740            "/assets/shell/taskers-hooks.zsh"
741        ));
742        let fish_hooks = include_str!(concat!(
743            env!("CARGO_MANIFEST_DIR"),
744            "/assets/shell/taskers-hooks.fish"
745        ));
746        let agent_proxy = include_str!(concat!(
747            env!("CARGO_MANIFEST_DIR"),
748            "/assets/shell/taskers-agent-proxy.sh"
749        ));
750
751        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
752            assert!(
753                asset.contains("TASKERS_SURFACE_ID"),
754                "expected asset to require TASKERS_SURFACE_ID"
755            );
756            assert!(
757                asset.contains("TASKERS_TTY_NAME"),
758                "expected asset to require TASKERS_TTY_NAME"
759            );
760        }
761        assert!(
762            agent_proxy.contains("TASKERS_AGENT_PROXY_ACTIVE"),
763            "expected proxy asset to keep loop-prevention guard"
764        );
765    }
766
767    #[test]
768    fn shell_hooks_only_treat_agent_identity_as_live_process_state() {
769        let bash_hooks = include_str!(concat!(
770            env!("CARGO_MANIFEST_DIR"),
771            "/assets/shell/taskers-hooks.bash"
772        ));
773        let zsh_hooks = include_str!(concat!(
774            env!("CARGO_MANIFEST_DIR"),
775            "/assets/shell/taskers-hooks.zsh"
776        ));
777        let fish_hooks = include_str!(concat!(
778            env!("CARGO_MANIFEST_DIR"),
779            "/assets/shell/taskers-hooks.fish"
780        ));
781
782        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
783            assert!(
784                !asset.contains("TASKERS_PANE_AGENT_KIND"),
785                "expected hook asset to avoid sticky pane-level agent identity"
786            );
787        }
788    }
789
790    #[test]
791    fn shell_assets_do_not_auto_report_completed_on_clean_or_interrupted_exit() {
792        let bash_hooks = include_str!(concat!(
793            env!("CARGO_MANIFEST_DIR"),
794            "/assets/shell/taskers-hooks.bash"
795        ));
796        let zsh_hooks = include_str!(concat!(
797            env!("CARGO_MANIFEST_DIR"),
798            "/assets/shell/taskers-hooks.zsh"
799        ));
800        let fish_hooks = include_str!(concat!(
801            env!("CARGO_MANIFEST_DIR"),
802            "/assets/shell/taskers-hooks.fish"
803        ));
804        let agent_proxy = include_str!(concat!(
805            env!("CARGO_MANIFEST_DIR"),
806            "/assets/shell/taskers-agent-proxy.sh"
807        ));
808
809        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
810            assert!(
811                !asset.contains("taskers__emit_with_metadata completed"),
812                "expected hook asset to avoid auto-emitting completed on bare agent exit"
813            );
814        }
815        assert!(
816            !agent_proxy.contains("emit_signal completed"),
817            "expected proxy to avoid auto-emitting completed on bare agent exit"
818        );
819        assert!(
820            !agent_proxy.contains("emit_signal error"),
821            "expected proxy to avoid owning stop/error signaling"
822        );
823    }
824
825    #[test]
826    fn shell_hooks_invalidate_metadata_cache_after_agent_exit() {
827        let bash_hooks = include_str!(concat!(
828            env!("CARGO_MANIFEST_DIR"),
829            "/assets/shell/taskers-hooks.bash"
830        ));
831        let zsh_hooks = include_str!(concat!(
832            env!("CARGO_MANIFEST_DIR"),
833            "/assets/shell/taskers-hooks.zsh"
834        ));
835        let fish_hooks = include_str!(concat!(
836            env!("CARGO_MANIFEST_DIR"),
837            "/assets/shell/taskers-hooks.fish"
838        ));
839
840        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
841            assert!(
842                asset.contains("TASKERS_LAST_META_AGENT"),
843                "expected hook asset to invalidate cached agent metadata after exit"
844            );
845            assert!(
846                asset.contains("TASKERS_LAST_META_AGENT_ACTIVE"),
847                "expected hook asset to invalidate cached agent-active metadata after exit"
848            );
849        }
850    }
851
852    #[test]
853    fn agent_proxy_owns_explicit_surface_agent_lifecycle_commands() {
854        let bash_hooks = include_str!(concat!(
855            env!("CARGO_MANIFEST_DIR"),
856            "/assets/shell/taskers-hooks.bash"
857        ));
858        let zsh_hooks = include_str!(concat!(
859            env!("CARGO_MANIFEST_DIR"),
860            "/assets/shell/taskers-hooks.zsh"
861        ));
862        let fish_hooks = include_str!(concat!(
863            env!("CARGO_MANIFEST_DIR"),
864            "/assets/shell/taskers-hooks.fish"
865        ));
866        let agent_proxy = include_str!(concat!(
867            env!("CARGO_MANIFEST_DIR"),
868            "/assets/shell/taskers-agent-proxy.sh"
869        ));
870
871        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
872            assert!(
873                !asset.contains("surface agent-start"),
874                "expected hook asset to leave explicit lifecycle start to the proxy"
875            );
876            assert!(
877                !asset.contains("surface agent-stop"),
878                "expected hook asset to leave explicit lifecycle stop to the proxy"
879            );
880        }
881        assert!(
882            agent_proxy.contains("surface agent-start"),
883            "expected proxy asset to emit explicit surface agent start commands"
884        );
885        assert!(
886            agent_proxy.contains("surface agent-stop"),
887            "expected proxy asset to emit explicit surface agent stop commands"
888        );
889    }
890
891    #[test]
892    fn embedded_zsh_codex_command_emits_surface_lifecycle_via_proxy() {
893        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
894        let runtime_root = unique_temp_dir("taskers-runtime-proxy-clean");
895        install_runtime_assets(&runtime_root).expect("install runtime assets");
896        super::install_agent_shims(&runtime_root).expect("install agent shims");
897
898        let home_dir = runtime_root.join("home");
899        let real_bin_dir = runtime_root.join("real-bin");
900        fs::create_dir_all(&home_dir).expect("home dir");
901        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
902
903        let taskersctl_path = runtime_root.join("taskersctl");
904        let args_log = runtime_root.join("codex-args.log");
905        write_executable(
906            &taskersctl_path,
907            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
908        );
909        write_executable(
910            &real_bin_dir.join("codex"),
911            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CODEX_ARGS_LOG\"\nnotify_script=\nprev=\nfor arg in \"$@\"; do\n  if [ \"$prev\" = \"-c\" ]; then\n    notify_script=$(printf '%s' \"$arg\" | sed -n 's/^notify=\\[\"bash\",\"\\([^\"]*\\)\"\\]$/\\1/p')\n    prev=\n    continue\n  fi\n  prev=$arg\ndone\nif [ -n \"$notify_script\" ]; then\n  \"$notify_script\" '{\"last-assistant-message\":\"Turn complete\"}'\nfi\nprintf 'fake codex\\n'\nexit 0\n",
912        );
913
914        let original_home = std::env::var_os("HOME");
915        let original_path = std::env::var_os("PATH");
916        let original_zdotdir = std::env::var_os("ZDOTDIR");
917        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
918        let test_log = runtime_root.join("taskersctl.log");
919        unsafe {
920            std::env::set_var("HOME", &home_dir);
921            std::env::remove_var("ZDOTDIR");
922            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
923            std::env::remove_var("TASKERS_SHELL_PROFILE");
924            std::env::set_var(
925                "PATH",
926                format!(
927                    "{}:{}",
928                    real_bin_dir.display(),
929                    original_path
930                        .as_deref()
931                        .map(|value| value.to_string_lossy().into_owned())
932                        .unwrap_or_default()
933                ),
934            );
935        }
936
937        let integration = ShellIntegration {
938            root: runtime_root.clone(),
939            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
940            real_shell: zsh_path,
941        };
942        let mut launch = integration.launch_spec();
943        launch.args.push("-c".into());
944        launch.args.push("codex".into());
945        launch.env.insert(
946            "TASKERS_CTL_PATH".into(),
947            taskersctl_path.display().to_string(),
948        );
949        launch
950            .env
951            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
952        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
953        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
954        launch
955            .env
956            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
957        launch
958            .env
959            .insert("FAKE_CODEX_ARGS_LOG".into(), args_log.display().to_string());
960
961        let mut spec = CommandSpec::new(launch.program.display().to_string());
962        spec.args = launch.args;
963        spec.env = launch.env;
964
965        let spawned = PtySession::spawn(&spec).expect("spawn shell");
966        let mut reader = spawned.reader;
967        let mut buffer = [0u8; 1024];
968        let mut output = String::new();
969        loop {
970            let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
971            if bytes_read == 0 {
972                break;
973            }
974            output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
975        }
976
977        let log = fs::read_to_string(&test_log)
978            .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
979        let codex_args = fs::read_to_string(&args_log)
980            .unwrap_or_else(|error| panic!("read codex args log failed: {error}; output={output}"));
981        assert!(
982            codex_args.contains("-c\n") || codex_args.contains("-c "),
983            "expected codex wrapper to inject config override, got: {codex_args}"
984        );
985        assert!(
986            codex_args.contains("notify=[\"bash\",\""),
987            "expected codex wrapper to inject notify helper override, got: {codex_args}"
988        );
989        assert!(
990            log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
991            "expected start lifecycle in log, got: {log}"
992        );
993        assert!(
994            log.contains("agent-hook stop --workspace ws --pane pn --surface sf --agent codex --title Codex --message Turn complete"),
995            "expected codex notify helper to emit stop hook, got: {log}"
996        );
997        assert!(
998            log.contains(
999                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1000            ),
1001            "expected stop lifecycle in log, got: {log}"
1002        );
1003
1004        restore_env_var("HOME", original_home);
1005        restore_env_var("PATH", original_path);
1006        restore_env_var("ZDOTDIR", original_zdotdir);
1007        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1008    }
1009
1010    #[test]
1011    fn embedded_zsh_claude_command_injects_taskers_hooks_and_process_lifecycle() {
1012        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1013        let runtime_root = unique_temp_dir("taskers-runtime-proxy-claude");
1014        install_runtime_assets(&runtime_root).expect("install runtime assets");
1015        super::install_agent_shims(&runtime_root).expect("install agent shims");
1016
1017        let home_dir = runtime_root.join("home");
1018        let real_bin_dir = runtime_root.join("real-bin");
1019        fs::create_dir_all(&home_dir).expect("home dir");
1020        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1021
1022        let taskersctl_path = runtime_root.join("taskersctl");
1023        let args_log = runtime_root.join("claude-args.log");
1024        write_executable(
1025            &taskersctl_path,
1026            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1027        );
1028        write_executable(
1029            &real_bin_dir.join("claude"),
1030            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CLAUDE_ARGS_LOG\"\nexit 0\n",
1031        );
1032
1033        let original_home = std::env::var_os("HOME");
1034        let original_path = std::env::var_os("PATH");
1035        let original_zdotdir = std::env::var_os("ZDOTDIR");
1036        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1037        let test_log = runtime_root.join("taskersctl.log");
1038        unsafe {
1039            std::env::set_var("HOME", &home_dir);
1040            std::env::remove_var("ZDOTDIR");
1041            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1042            std::env::remove_var("TASKERS_SHELL_PROFILE");
1043            std::env::set_var(
1044                "PATH",
1045                format!(
1046                    "{}:{}",
1047                    real_bin_dir.display(),
1048                    original_path
1049                        .as_deref()
1050                        .map(|value| value.to_string_lossy().into_owned())
1051                        .unwrap_or_default()
1052                ),
1053            );
1054        }
1055
1056        let integration = ShellIntegration {
1057            root: runtime_root.clone(),
1058            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1059            real_shell: zsh_path,
1060        };
1061        let mut launch = integration.launch_spec();
1062        launch.args.push("-c".into());
1063        launch.args.push("claude --help".into());
1064        launch.env.insert(
1065            "TASKERS_CTL_PATH".into(),
1066            taskersctl_path.display().to_string(),
1067        );
1068        launch
1069            .env
1070            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1071        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1072        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1073        launch
1074            .env
1075            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1076        launch.env.insert(
1077            "FAKE_CLAUDE_ARGS_LOG".into(),
1078            args_log.display().to_string(),
1079        );
1080
1081        let mut spec = CommandSpec::new(launch.program.display().to_string());
1082        spec.args = launch.args;
1083        spec.env = launch.env;
1084
1085        let spawned = PtySession::spawn(&spec).expect("spawn shell");
1086        let mut reader = spawned.reader;
1087        let mut buffer = [0u8; 1024];
1088        let mut output = String::new();
1089        loop {
1090            let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1091            if bytes_read == 0 {
1092                break;
1093            }
1094            output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1095        }
1096
1097        let log = fs::read_to_string(&test_log)
1098            .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1099        let claude_args = fs::read_to_string(&args_log)
1100            .unwrap_or_else(|error| panic!("read claude args log failed: {error}; output={output}"));
1101        assert!(
1102            claude_args.contains("--settings"),
1103            "expected claude wrapper to inject hook settings, got: {claude_args}"
1104        );
1105        assert!(
1106            claude_args.contains("taskers-claude-hook.sh user-prompt-submit"),
1107            "expected claude wrapper to inject prompt-submit hook, got: {claude_args}"
1108        );
1109        assert!(
1110            claude_args.contains("taskers-claude-hook.sh stop"),
1111            "expected claude wrapper to inject stop hook, got: {claude_args}"
1112        );
1113        assert!(
1114            log.contains(
1115                "surface agent-start --workspace ws --pane pn --surface sf --agent claude"
1116            ),
1117            "expected start lifecycle in log, got: {log}"
1118        );
1119        assert!(
1120            log.contains(
1121                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1122            ),
1123            "expected stop lifecycle in log, got: {log}"
1124        );
1125
1126        restore_env_var("HOME", original_home);
1127        restore_env_var("PATH", original_path);
1128        restore_env_var("ZDOTDIR", original_zdotdir);
1129        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1130    }
1131
1132    #[test]
1133    fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() {
1134        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1135        let runtime_root = unique_temp_dir("taskers-runtime-proxy-interrupt");
1136        install_runtime_assets(&runtime_root).expect("install runtime assets");
1137        super::install_agent_shims(&runtime_root).expect("install agent shims");
1138
1139        let home_dir = runtime_root.join("home");
1140        let real_bin_dir = runtime_root.join("real-bin");
1141        fs::create_dir_all(&home_dir).expect("home dir");
1142        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1143
1144        let taskersctl_path = runtime_root.join("taskersctl");
1145        write_executable(
1146            &taskersctl_path,
1147            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1148        );
1149        write_executable(
1150            &real_bin_dir.join("codex"),
1151            "#!/bin/sh\ntrap 'exit 130' INT\nwhile :; do sleep 1; done\n",
1152        );
1153
1154        let original_home = std::env::var_os("HOME");
1155        let original_path = std::env::var_os("PATH");
1156        let original_zdotdir = std::env::var_os("ZDOTDIR");
1157        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1158        let test_log = runtime_root.join("taskersctl.log");
1159        unsafe {
1160            std::env::set_var("HOME", &home_dir);
1161            std::env::remove_var("ZDOTDIR");
1162            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1163            std::env::remove_var("TASKERS_SHELL_PROFILE");
1164            std::env::set_var(
1165                "PATH",
1166                format!(
1167                    "{}:{}",
1168                    real_bin_dir.display(),
1169                    original_path
1170                        .as_deref()
1171                        .map(|value| value.to_string_lossy().into_owned())
1172                        .unwrap_or_default()
1173                ),
1174            );
1175        }
1176
1177        let integration = ShellIntegration {
1178            root: runtime_root.clone(),
1179            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1180            real_shell: zsh_path,
1181        };
1182        let mut launch = integration.launch_spec();
1183        launch.args.push("-c".into());
1184        launch.args.push("codex".into());
1185        launch.env.insert(
1186            "TASKERS_CTL_PATH".into(),
1187            taskersctl_path.display().to_string(),
1188        );
1189        launch
1190            .env
1191            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1192        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1193        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1194        launch
1195            .env
1196            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1197
1198        let mut spec = CommandSpec::new(launch.program.display().to_string());
1199        spec.args = launch.args;
1200        spec.env = launch.env;
1201
1202        let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1203        std::thread::sleep(Duration::from_millis(250));
1204        spawned
1205            .session
1206            .write_all(b"\x03")
1207            .expect("send ctrl-c to shell");
1208
1209        let mut reader = spawned.reader;
1210        let mut buffer = [0u8; 1024];
1211        while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1212
1213        let log = fs::read_to_string(&test_log).expect("read lifecycle log");
1214        assert!(
1215            log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1216            "expected start lifecycle in log, got: {log}"
1217        );
1218        assert!(
1219            log.contains(
1220                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 130"
1221            ),
1222            "expected interrupted stop lifecycle in log, got: {log}"
1223        );
1224
1225        restore_env_var("HOME", original_home);
1226        restore_env_var("PATH", original_path);
1227        restore_env_var("ZDOTDIR", original_zdotdir);
1228        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1229    }
1230
1231    fn unique_temp_dir(prefix: &str) -> PathBuf {
1232        let unique = SystemTime::now()
1233            .duration_since(SystemTime::UNIX_EPOCH)
1234            .expect("time")
1235            .as_nanos();
1236        std::env::temp_dir().join(format!("{prefix}-{unique}"))
1237    }
1238
1239    fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1240        unsafe {
1241            match value {
1242                Some(value) => std::env::set_var(key, value),
1243                None => std::env::remove_var(key),
1244            }
1245        }
1246    }
1247
1248    fn write_executable(path: &PathBuf, content: &str) {
1249        fs::write(path, content).expect("write script");
1250        #[cfg(unix)]
1251        {
1252            let mut permissions = fs::metadata(path).expect("metadata").permissions();
1253            permissions.set_mode(0o755);
1254            fs::set_permissions(path, permissions).expect("chmod script");
1255        }
1256    }
1257}