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}