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 env.insert("TASKERS_SHELL_PROFILE".into(), profile.clone());
131
132 let mut args = Vec::new();
133 if profile == "clean" {
134 args.push("--no-config".into());
135 }
136 args.push("--interactive".into());
137 args.push("--init-command".into());
138 args.push(fish_source_command());
139
140 ShellLaunchSpec {
141 program: self.wrapper_path.clone(),
142 args,
143 env,
144 }
145 }
146 ShellKind::Fish => ShellLaunchSpec {
147 program: self.real_shell.clone(),
148 args: vec!["--no-config".into(), "--interactive".into()],
149 env: self.base_env(),
150 },
151 ShellKind::Zsh if !integration_disabled => {
152 let mut env = self.base_env();
153 env.insert(
154 "TASKERS_REAL_SHELL".into(),
155 self.real_shell.display().to_string(),
156 );
157 env.insert("TASKERS_SHELL_PROFILE".into(), profile.clone());
158 env.insert(
159 "ZDOTDIR".into(),
160 zsh_runtime_dir(&self.root).display().to_string(),
161 );
162 if let Some(value) = env::var_os("ZDOTDIR").or_else(|| env::var_os("HOME")) {
163 env.insert(
164 "TASKERS_USER_ZDOTDIR".into(),
165 value.to_string_lossy().into_owned(),
166 );
167 }
168 let args = if profile == "clean" {
169 vec!["-d".into(), "-i".into()]
170 } else {
171 vec!["-i".into()]
172 };
173
174 ShellLaunchSpec {
175 program: self.wrapper_path.clone(),
176 args,
177 env,
178 }
179 }
180 ShellKind::Zsh => ShellLaunchSpec {
181 program: self.real_shell.clone(),
182 args: vec!["-d".into(), "-f".into(), "-i".into()],
183 env: self.base_env(),
184 },
185 ShellKind::Other => {
186 let mut env = self.base_env();
187 env.insert(
188 "TASKERS_REAL_SHELL".into(),
189 self.real_shell.display().to_string(),
190 );
191 ShellLaunchSpec {
192 program: self.wrapper_path.clone(),
193 args: Vec::new(),
194 env,
195 }
196 }
197 }
198 }
199
200 pub fn root(&self) -> &Path {
201 &self.root
202 }
203}
204
205impl ShellIntegration {
206 fn base_env(&self) -> BTreeMap<String, String> {
207 let mut env = base_env();
208 env.insert(
209 "TASKERS_SHELL_INTEGRATION_DIR".into(),
210 self.root.display().to_string(),
211 );
212 if let Some(path) = resolve_taskersctl_path() {
213 env.insert("TASKERS_CTL_PATH".into(), path.display().to_string());
214 }
215 let shim_dir = self.root.join("bin");
216 env.insert("PATH".into(), prepend_path_entry(&shim_dir));
217 env
218 }
219}
220
221pub fn install_shell_integration(configured_shell: Option<&str>) -> Result<ShellIntegration> {
222 ShellIntegration::install(configured_shell)
223}
224
225pub fn scrub_inherited_terminal_env() {
226 for key in INHERITED_TERMINAL_ENV_KEYS {
227 unsafe {
228 env::remove_var(key);
229 }
230 }
231}
232
233pub fn default_shell_program() -> PathBuf {
234 login_shell_from_passwd()
235 .or_else(shell_from_env)
236 .unwrap_or_else(|| PathBuf::from("/bin/sh"))
237}
238
239pub fn validate_shell_program(configured_shell: Option<&str>) -> Result<Option<PathBuf>> {
240 configured_shell
241 .and_then(normalize_shell_override)
242 .map(|value| resolve_shell_override(&value))
243 .transpose()
244}
245
246fn base_env() -> BTreeMap<String, String> {
247 let mut env = BTreeMap::new();
248 env.insert("TASKERS_EMBEDDED".into(), "1".into());
249 env.insert("TERM_PROGRAM".into(), "taskers".into());
250 env
251}
252
253fn install_agent_shims(root: &Path) -> Result<()> {
254 let shim_dir = root.join("bin");
255 fs::create_dir_all(&shim_dir)
256 .with_context(|| format!("failed to create {}", shim_dir.display()))?;
257 for (name, target_path) in [
258 ("codex", root.join("taskers-agent-codex.sh")),
259 ("claude", root.join("taskers-agent-claude.sh")),
260 ("claude-code", root.join("taskers-agent-claude.sh")),
261 ("opencode", root.join("taskers-agent-proxy.sh")),
262 ("aider", root.join("taskers-agent-proxy.sh")),
263 ] {
264 let shim_path = shim_dir.join(name);
265 if shim_path.symlink_metadata().is_ok() {
266 fs::remove_file(&shim_path)
267 .with_context(|| format!("failed to replace {}", shim_path.display()))?;
268 }
269
270 #[cfg(unix)]
271 std::os::unix::fs::symlink(&target_path, &shim_path).with_context(|| {
272 format!(
273 "failed to symlink {} -> {}",
274 shim_path.display(),
275 target_path.display()
276 )
277 })?;
278
279 #[cfg(not(unix))]
280 fs::copy(&target_path, &shim_path).with_context(|| {
281 format!(
282 "failed to copy {} -> {}",
283 target_path.display(),
284 shim_path.display()
285 )
286 })?;
287 }
288
289 Ok(())
290}
291
292fn prepend_path_entry(entry: &Path) -> String {
293 let mut parts = vec![entry.display().to_string()];
294 if let Some(path) = env::var_os("PATH") {
295 parts.extend(
296 env::split_paths(&path)
297 .filter(|candidate| candidate != entry)
298 .map(|candidate| candidate.display().to_string()),
299 );
300 }
301 parts.join(":")
302}
303
304fn runtime_root() -> PathBuf {
305 taskers_paths::default_shell_runtime_dir()
306}
307
308fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> {
309 if let Some(parent) = path.parent() {
310 fs::create_dir_all(parent)
311 .with_context(|| format!("failed to create {}", parent.display()))?;
312 }
313
314 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
315
316 #[cfg(unix)]
317 if executable {
318 use std::os::unix::fs::PermissionsExt;
319
320 let mut permissions = fs::metadata(path)
321 .with_context(|| format!("failed to stat {}", path.display()))?
322 .permissions();
323 permissions.set_mode(0o755);
324 fs::set_permissions(path, permissions)
325 .with_context(|| format!("failed to chmod {}", path.display()))?;
326 }
327
328 Ok(())
329}
330
331fn resolve_taskersctl_path() -> Option<PathBuf> {
332 if let Some(path) = std::env::current_exe()
333 .ok()
334 .and_then(|path| path.parent().map(|parent| parent.join("taskersctl")))
335 .filter(|path| path.is_file())
336 {
337 return Some(path);
338 }
339
340 if let Some(path) = env::var_os("TASKERS_CTL_PATH")
341 .map(PathBuf::from)
342 .filter(|path| path.is_file())
343 {
344 return Some(path);
345 }
346
347 if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
348 for candidate in [
349 home.join(".cargo").join("bin").join("taskersctl"),
350 home.join(".local").join("bin").join("taskersctl"),
351 ] {
352 if candidate.is_file() {
353 return Some(candidate);
354 }
355 }
356 }
357
358 let path_var = env::var_os("PATH")?;
359 env::split_paths(&path_var)
360 .map(|entry| entry.join("taskersctl"))
361 .find(|candidate| candidate.is_file())
362}
363
364fn resolve_shell_program(configured_shell: Option<&str>) -> Result<PathBuf> {
365 if let Some(shell) = configured_shell.and_then(|value| normalize_shell_override(value)) {
366 return resolve_shell_override(&shell)
367 .with_context(|| format!("failed to resolve configured shell {shell}"));
368 }
369
370 Ok(default_shell_program())
371}
372
373fn shell_kind(path: &Path) -> ShellKind {
374 let name = path
375 .file_name()
376 .and_then(|value| value.to_str())
377 .unwrap_or_default()
378 .trim_start_matches('-');
379
380 match name {
381 "bash" => ShellKind::Bash,
382 "fish" => ShellKind::Fish,
383 "zsh" => ShellKind::Zsh,
384 _ => ShellKind::Other,
385 }
386}
387
388fn normalize_shell_override(value: &str) -> Option<String> {
389 let trimmed = value.trim();
390 if trimmed.is_empty() {
391 None
392 } else {
393 Some(trimmed.to_string())
394 }
395}
396
397fn resolve_shell_override(value: &str) -> Result<PathBuf> {
398 let expanded = expand_home_prefix(value);
399 let candidate = PathBuf::from(&expanded);
400 if expanded.contains('/') {
401 anyhow::ensure!(
402 candidate.is_file(),
403 "shell program {} does not exist",
404 candidate.display()
405 );
406 return Ok(candidate);
407 }
408
409 let path_var = env::var_os("PATH").unwrap_or_default();
410 let resolved = env::split_paths(&path_var)
411 .map(|entry| entry.join(&candidate))
412 .find(|entry| entry.is_file());
413 resolved.with_context(|| format!("shell program {value} was not found in PATH"))
414}
415
416fn expand_home_prefix(value: &str) -> String {
417 if value == "~" {
418 return env::var("HOME").unwrap_or_else(|_| value.to_string());
419 }
420
421 if let Some(suffix) = value.strip_prefix("~/") {
422 if let Some(home) = env::var_os("HOME") {
423 return PathBuf::from(home).join(suffix).display().to_string();
424 }
425 }
426
427 value.to_string()
428}
429
430fn shell_from_env() -> Option<PathBuf> {
431 env::var_os("SHELL")
432 .map(PathBuf::from)
433 .filter(|path| !path.as_os_str().is_empty())
434}
435
436#[cfg(unix)]
437fn login_shell_from_passwd() -> Option<PathBuf> {
438 let uid = unsafe { libc::geteuid() };
439 let mut pwd = std::mem::MaybeUninit::<passwd>::uninit();
440 let mut result = std::ptr::null_mut::<passwd>();
441 let mut buffer = vec![0u8; passwd_buffer_size()];
442
443 let status = unsafe {
444 libc::getpwuid_r(
445 uid,
446 pwd.as_mut_ptr(),
447 buffer.as_mut_ptr().cast(),
448 buffer.len(),
449 &mut result,
450 )
451 };
452 if status != 0 || result.is_null() {
453 return None;
454 }
455
456 let pwd = unsafe { pwd.assume_init() };
457 if pwd.pw_shell.is_null() {
458 return None;
459 }
460
461 let shell = unsafe { CStr::from_ptr(pwd.pw_shell) }.to_bytes().to_vec();
462 if shell.is_empty() {
463 return None;
464 }
465
466 Some(PathBuf::from(std::ffi::OsString::from_vec(shell)))
467}
468
469#[cfg(not(unix))]
470fn login_shell_from_passwd() -> Option<PathBuf> {
471 None
472}
473
474#[cfg(unix)]
475fn passwd_buffer_size() -> usize {
476 let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
477 if size <= 0 { 4096 } else { size as usize }
478}
479
480#[cfg(not(unix))]
481fn passwd_buffer_size() -> usize {
482 4096
483}
484
485fn fish_source_command() -> String {
486 r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#.into()
487}
488
489fn zsh_runtime_dir(root: &Path) -> PathBuf {
490 root.join("zsh")
491}
492
493fn install_runtime_assets(root: &Path) -> Result<()> {
494 write_asset(
495 &root.join("taskers-shell-wrapper.sh"),
496 include_str!(concat!(
497 env!("CARGO_MANIFEST_DIR"),
498 "/assets/shell/taskers-shell-wrapper.sh"
499 )),
500 true,
501 )?;
502 write_asset(
503 &root.join("bash").join("taskers.bashrc"),
504 include_str!(concat!(
505 env!("CARGO_MANIFEST_DIR"),
506 "/assets/shell/bash/taskers.bashrc"
507 )),
508 false,
509 )?;
510 write_asset(
511 &root.join("taskers-hooks.bash"),
512 include_str!(concat!(
513 env!("CARGO_MANIFEST_DIR"),
514 "/assets/shell/taskers-hooks.bash"
515 )),
516 false,
517 )?;
518 write_asset(
519 &root.join("taskers-hooks.fish"),
520 include_str!(concat!(
521 env!("CARGO_MANIFEST_DIR"),
522 "/assets/shell/taskers-hooks.fish"
523 )),
524 false,
525 )?;
526 write_asset(
527 &root.join("taskers-hooks.zsh"),
528 include_str!(concat!(
529 env!("CARGO_MANIFEST_DIR"),
530 "/assets/shell/taskers-hooks.zsh"
531 )),
532 false,
533 )?;
534 write_asset(
535 &zsh_runtime_dir(root).join(".zshenv"),
536 include_str!(concat!(
537 env!("CARGO_MANIFEST_DIR"),
538 "/assets/shell/zsh/.zshenv"
539 )),
540 false,
541 )?;
542 write_asset(
543 &zsh_runtime_dir(root).join(".zshrc"),
544 include_str!(concat!(
545 env!("CARGO_MANIFEST_DIR"),
546 "/assets/shell/zsh/.zshrc"
547 )),
548 false,
549 )?;
550 write_asset(
551 &zsh_runtime_dir(root).join(".zcompdump"),
552 include_str!(concat!(
553 env!("CARGO_MANIFEST_DIR"),
554 "/assets/shell/zsh/.zcompdump"
555 )),
556 false,
557 )?;
558 write_asset(
559 &root.join("taskers-codex-notify.sh"),
560 include_str!(concat!(
561 env!("CARGO_MANIFEST_DIR"),
562 "/assets/shell/taskers-codex-notify.sh"
563 )),
564 true,
565 )?;
566 write_asset(
567 &root.join("taskers-claude-hook.sh"),
568 include_str!(concat!(
569 env!("CARGO_MANIFEST_DIR"),
570 "/assets/shell/taskers-claude-hook.sh"
571 )),
572 true,
573 )?;
574 write_asset(
575 &root.join("taskers-agent-codex.sh"),
576 include_str!(concat!(
577 env!("CARGO_MANIFEST_DIR"),
578 "/assets/shell/taskers-agent-codex.sh"
579 )),
580 true,
581 )?;
582 write_asset(
583 &root.join("taskers-agent-claude.sh"),
584 include_str!(concat!(
585 env!("CARGO_MANIFEST_DIR"),
586 "/assets/shell/taskers-agent-claude.sh"
587 )),
588 true,
589 )?;
590 write_asset(
591 &root.join("taskers-agent-proxy.sh"),
592 include_str!(concat!(
593 env!("CARGO_MANIFEST_DIR"),
594 "/assets/shell/taskers-agent-proxy.sh"
595 )),
596 true,
597 )?;
598 Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603 use std::{
604 fs,
605 path::PathBuf,
606 process::Command,
607 sync::Mutex,
608 time::{Duration, SystemTime},
609 };
610
611 #[cfg(unix)]
612 use std::os::unix::fs::PermissionsExt;
613
614 use super::{
615 INHERITED_TERMINAL_ENV_KEYS, ShellIntegration, expand_home_prefix, fish_source_command,
616 install_runtime_assets, normalize_shell_override, resolve_shell_override, zsh_runtime_dir,
617 };
618 use crate::{CommandSpec, PtySession};
619
620 static ENV_LOCK: Mutex<()> = Mutex::new(());
621
622 #[test]
623 fn shell_override_normalizes_blank_values() {
624 assert_eq!(normalize_shell_override(""), None);
625 assert_eq!(normalize_shell_override(" "), None);
626 assert_eq!(
627 normalize_shell_override(" /usr/bin/fish "),
628 Some("/usr/bin/fish".into())
629 );
630 }
631
632 #[test]
633 fn fish_source_command_uses_runtime_env_path() {
634 assert_eq!(
635 fish_source_command(),
636 r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#
637 );
638 }
639
640 #[test]
641 fn zsh_launch_spec_routes_through_runtime_zdotdir() {
642 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
643 let original_zdotdir = std::env::var_os("ZDOTDIR");
644 let original_home = std::env::var_os("HOME");
645 unsafe {
646 std::env::set_var("HOME", "/tmp/taskers-home");
647 std::env::set_var("ZDOTDIR", "/tmp/user-zdotdir");
648 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
649 std::env::remove_var("TASKERS_SHELL_PROFILE");
650 }
651
652 let integration = ShellIntegration {
653 root: PathBuf::from("/tmp/taskers-runtime"),
654 wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
655 real_shell: PathBuf::from("/usr/bin/zsh"),
656 };
657 let spec = integration.launch_spec();
658
659 assert_eq!(
660 spec.env.get("ZDOTDIR").map(String::as_str),
661 Some("/tmp/taskers-runtime/zsh")
662 );
663 assert_eq!(
664 spec.env.get("TASKERS_USER_ZDOTDIR").map(String::as_str),
665 Some("/tmp/user-zdotdir")
666 );
667 assert_eq!(spec.program, integration.wrapper_path);
668 assert_eq!(spec.args, vec!["-i"]);
669
670 unsafe {
671 match original_zdotdir {
672 Some(value) => std::env::set_var("ZDOTDIR", value),
673 None => std::env::remove_var("ZDOTDIR"),
674 }
675 match original_home {
676 Some(value) => std::env::set_var("HOME", value),
677 None => std::env::remove_var("HOME"),
678 }
679 }
680 }
681
682 #[test]
683 fn zsh_runtime_dir_is_nested_under_runtime_root() {
684 assert_eq!(
685 zsh_runtime_dir(&PathBuf::from("/tmp/taskers-runtime")),
686 PathBuf::from("/tmp/taskers-runtime/zsh")
687 );
688 }
689
690 #[test]
691 fn install_runtime_assets_writes_zsh_runtime_files() {
692 let root = unique_temp_dir("taskers-runtime-test");
693 install_runtime_assets(&root).expect("install runtime assets");
694
695 assert!(root.join("taskers-shell-wrapper.sh").is_file());
696 assert!(root.join("taskers-hooks.bash").is_file());
697 assert!(root.join("taskers-hooks.fish").is_file());
698 assert!(root.join("taskers-hooks.zsh").is_file());
699 assert!(root.join("taskers-codex-notify.sh").is_file());
700 assert!(root.join("taskers-claude-hook.sh").is_file());
701 assert!(root.join("taskers-agent-codex.sh").is_file());
702 assert!(root.join("taskers-agent-claude.sh").is_file());
703 assert!(root.join("taskers-agent-proxy.sh").is_file());
704 assert!(zsh_runtime_dir(&root).join(".zshenv").is_file());
705 assert!(zsh_runtime_dir(&root).join(".zshrc").is_file());
706 assert!(zsh_runtime_dir(&root).join(".zcompdump").is_file());
707
708 fs::remove_dir_all(&root).expect("cleanup runtime assets");
709 }
710
711 #[test]
712 fn home_prefix_expansion_without_home_keeps_original_shape() {
713 let original = "~/bin/fish";
714 let expanded = expand_home_prefix(original);
715 if std::env::var_os("HOME").is_some() {
716 assert_ne!(expanded, original);
717 } else {
718 assert_eq!(expanded, original);
719 }
720 }
721
722 #[test]
723 fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() {
724 for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] {
725 assert!(
726 INHERITED_TERMINAL_ENV_KEYS.contains(&key),
727 "expected {key} to be scrubbed from inherited terminal env"
728 );
729 }
730 }
731
732 #[test]
733 fn shell_wrapper_exports_taskers_tty_name() {
734 let wrapper = include_str!(concat!(
735 env!("CARGO_MANIFEST_DIR"),
736 "/assets/shell/taskers-shell-wrapper.sh"
737 ));
738 assert!(
739 wrapper.contains("TASKERS_TTY_NAME"),
740 "expected wrapper to export TASKERS_TTY_NAME"
741 );
742 }
743
744 #[test]
745 fn shell_wrapper_routes_terminal_sessions_through_sidecar_attach() {
746 let wrapper = include_str!(concat!(
747 env!("CARGO_MANIFEST_DIR"),
748 "/assets/shell/taskers-shell-wrapper.sh"
749 ));
750 assert!(
751 wrapper.contains("TASKERS_TERMINAL_SOCKET"),
752 "expected wrapper to branch on terminal socket availability"
753 );
754 assert!(
755 wrapper.contains("TASKERS_TERMINAL_SESSION_ID"),
756 "expected wrapper to require terminal session ids for session attach"
757 );
758 assert!(
759 wrapper.contains("session attach"),
760 "expected wrapper to delegate continuity startup to taskersctl session attach"
761 );
762 }
763
764 #[test]
765 fn shell_wrapper_handles_fish_and_zsh_default_launch_modes() {
766 let wrapper = include_str!(concat!(
767 env!("CARGO_MANIFEST_DIR"),
768 "/assets/shell/taskers-shell-wrapper.sh"
769 ));
770 assert!(
771 wrapper.contains("SHELL_PROFILE=${TASKERS_SHELL_PROFILE:-default}"),
772 "expected wrapper to honor TASKERS_SHELL_PROFILE when synthesizing default shell args"
773 );
774 assert!(
775 wrapper.contains("--init-command"),
776 "expected wrapper to synthesize fish init-command integration when no explicit args are passed"
777 );
778 assert!(
779 wrapper.contains("set -- -d -i"),
780 "expected wrapper to synthesize zsh default launch flags when no explicit args are passed"
781 );
782 assert!(
783 wrapper.contains("set -- --no-config --interactive --init-command"),
784 "expected clean-profile fish launches to keep sourcing taskers-hooks.fish"
785 );
786 }
787
788 #[test]
789 fn fish_and_zsh_launch_specs_preserve_shell_profile_env() {
790 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
791 let original_profile = std::env::var_os("TASKERS_SHELL_PROFILE");
792 let original_disabled = std::env::var_os("TASKERS_DISABLE_SHELL_INTEGRATION");
793 unsafe {
794 std::env::set_var("TASKERS_SHELL_PROFILE", "clean");
795 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
796 }
797
798 let fish_integration = ShellIntegration {
799 root: PathBuf::from("/tmp/taskers-runtime"),
800 wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
801 real_shell: PathBuf::from("/usr/bin/fish"),
802 };
803 let zsh_integration = ShellIntegration {
804 root: PathBuf::from("/tmp/taskers-runtime"),
805 wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
806 real_shell: PathBuf::from("/usr/bin/zsh"),
807 };
808
809 let fish_spec = fish_integration.launch_spec();
810 let zsh_spec = zsh_integration.launch_spec();
811
812 assert_eq!(
813 fish_spec
814 .env
815 .get("TASKERS_SHELL_PROFILE")
816 .map(String::as_str),
817 Some("clean")
818 );
819 assert_eq!(
820 zsh_spec
821 .env
822 .get("TASKERS_SHELL_PROFILE")
823 .map(String::as_str),
824 Some("clean")
825 );
826
827 unsafe {
828 match original_profile {
829 Some(value) => std::env::set_var("TASKERS_SHELL_PROFILE", value),
830 None => std::env::remove_var("TASKERS_SHELL_PROFILE"),
831 }
832 match original_disabled {
833 Some(value) => std::env::set_var("TASKERS_DISABLE_SHELL_INTEGRATION", value),
834 None => std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"),
835 }
836 }
837 }
838
839 #[test]
840 fn shell_hooks_and_proxy_require_surface_tty_identity() {
841 let bash_hooks = include_str!(concat!(
842 env!("CARGO_MANIFEST_DIR"),
843 "/assets/shell/taskers-hooks.bash"
844 ));
845 let zsh_hooks = include_str!(concat!(
846 env!("CARGO_MANIFEST_DIR"),
847 "/assets/shell/taskers-hooks.zsh"
848 ));
849 let fish_hooks = include_str!(concat!(
850 env!("CARGO_MANIFEST_DIR"),
851 "/assets/shell/taskers-hooks.fish"
852 ));
853 let agent_proxy = include_str!(concat!(
854 env!("CARGO_MANIFEST_DIR"),
855 "/assets/shell/taskers-agent-proxy.sh"
856 ));
857
858 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
859 assert!(
860 asset.contains("TASKERS_SURFACE_ID"),
861 "expected asset to require TASKERS_SURFACE_ID"
862 );
863 assert!(
864 asset.contains("TASKERS_TTY_NAME"),
865 "expected asset to require TASKERS_TTY_NAME"
866 );
867 }
868 assert!(
869 agent_proxy.contains("TASKERS_AGENT_PROXY_ACTIVE"),
870 "expected proxy asset to keep loop-prevention guard"
871 );
872 }
873
874 #[test]
875 fn shell_hooks_only_treat_agent_identity_as_live_process_state() {
876 let bash_hooks = include_str!(concat!(
877 env!("CARGO_MANIFEST_DIR"),
878 "/assets/shell/taskers-hooks.bash"
879 ));
880 let zsh_hooks = include_str!(concat!(
881 env!("CARGO_MANIFEST_DIR"),
882 "/assets/shell/taskers-hooks.zsh"
883 ));
884 let fish_hooks = include_str!(concat!(
885 env!("CARGO_MANIFEST_DIR"),
886 "/assets/shell/taskers-hooks.fish"
887 ));
888
889 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
890 assert!(
891 !asset.contains("TASKERS_PANE_AGENT_KIND"),
892 "expected hook asset to avoid sticky pane-level agent identity"
893 );
894 }
895 }
896
897 #[test]
898 fn shell_assets_do_not_auto_report_completed_on_clean_or_interrupted_exit() {
899 let bash_hooks = include_str!(concat!(
900 env!("CARGO_MANIFEST_DIR"),
901 "/assets/shell/taskers-hooks.bash"
902 ));
903 let zsh_hooks = include_str!(concat!(
904 env!("CARGO_MANIFEST_DIR"),
905 "/assets/shell/taskers-hooks.zsh"
906 ));
907 let fish_hooks = include_str!(concat!(
908 env!("CARGO_MANIFEST_DIR"),
909 "/assets/shell/taskers-hooks.fish"
910 ));
911 let agent_proxy = include_str!(concat!(
912 env!("CARGO_MANIFEST_DIR"),
913 "/assets/shell/taskers-agent-proxy.sh"
914 ));
915
916 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
917 assert!(
918 !asset.contains("taskers__emit_with_metadata completed"),
919 "expected hook asset to avoid auto-emitting completed on bare agent exit"
920 );
921 }
922 assert!(
923 !agent_proxy.contains("emit_signal completed"),
924 "expected proxy to avoid auto-emitting completed on bare agent exit"
925 );
926 assert!(
927 !agent_proxy.contains("emit_signal error"),
928 "expected proxy to avoid owning stop/error signaling"
929 );
930 }
931
932 #[test]
933 fn zsh_shell_hook_avoids_readonly_status_parameter_name() {
934 let zsh_hooks = include_str!(concat!(
935 env!("CARGO_MANIFEST_DIR"),
936 "/assets/shell/taskers-hooks.zsh"
937 ));
938
939 assert!(
940 !zsh_hooks.contains("local status="),
941 "expected zsh hooks to avoid assigning to zsh's readonly status parameter"
942 );
943 }
944
945 #[test]
946 fn zsh_shell_hook_tracks_directory_changes_for_metadata() {
947 let zsh_hooks = include_str!(concat!(
948 env!("CARGO_MANIFEST_DIR"),
949 "/assets/shell/taskers-hooks.zsh"
950 ));
951
952 assert!(
953 zsh_hooks.contains("add-zsh-hook chpwd taskers__on_chpwd")
954 || zsh_hooks.contains("chpwd_functions+=(taskers__on_chpwd)"),
955 "expected zsh hooks to refresh metadata on directory changes"
956 );
957 }
958
959 #[test]
960 fn zsh_shell_hook_prefers_shell_tty_and_supports_jj_repos() {
961 let zsh_hooks = include_str!(concat!(
962 env!("CARGO_MANIFEST_DIR"),
963 "/assets/shell/taskers-hooks.zsh"
964 ));
965
966 assert!(
967 zsh_hooks.contains("local current_tty=${TTY:-}"),
968 "expected zsh hooks to prefer zsh's built-in TTY variable"
969 );
970 assert!(
971 zsh_hooks.contains("jj root"),
972 "expected zsh hooks to support JJ repo root detection"
973 );
974 }
975
976 #[test]
977 fn shell_hooks_emit_boolean_agent_active_flags() {
978 let bash_hooks = include_str!(concat!(
979 env!("CARGO_MANIFEST_DIR"),
980 "/assets/shell/taskers-hooks.bash"
981 ));
982 let zsh_hooks = include_str!(concat!(
983 env!("CARGO_MANIFEST_DIR"),
984 "/assets/shell/taskers-hooks.zsh"
985 ));
986 let fish_hooks = include_str!(concat!(
987 env!("CARGO_MANIFEST_DIR"),
988 "/assets/shell/taskers-hooks.fish"
989 ));
990
991 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
992 assert!(
993 asset.contains("true"),
994 "expected hook asset to emit literal boolean true"
995 );
996 assert!(
997 asset.contains("false"),
998 "expected hook asset to emit literal boolean false"
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn embedded_zsh_emits_metadata_for_repo_cwd() {
1005 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1006 let runtime_root = unique_temp_dir("taskers-runtime-zsh-metadata");
1007 install_runtime_assets(&runtime_root).expect("install runtime assets");
1008
1009 let home_dir = runtime_root.join("home");
1010 let real_bin_dir = runtime_root.join("real-bin");
1011 let repo_dir = runtime_root.join("repo");
1012 fs::create_dir_all(&home_dir).expect("home dir");
1013 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1014 fs::create_dir_all(&repo_dir).expect("repo dir");
1015
1016 let taskersctl_path = runtime_root.join("taskersctl");
1017 let test_log = runtime_root.join("taskersctl.log");
1018 write_executable(
1019 &taskersctl_path,
1020 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1021 );
1022 write_executable(
1023 &real_bin_dir.join("git"),
1024 "#!/bin/sh\ncwd=\nif [ \"$1\" = \"-C\" ]; then cwd=$2; shift 2; fi\nif [ \"$1\" = \"rev-parse\" ] && [ \"$2\" = \"--show-toplevel\" ]; then printf '%s\\n' \"$cwd\"; exit 0; fi\nif [ \"$1\" = \"symbolic-ref\" ] && [ \"$2\" = \"--quiet\" ] && [ \"$3\" = \"--short\" ] && [ \"$4\" = \"HEAD\" ]; then printf 'main\\n'; exit 0; fi\nif [ \"$1\" = \"rev-parse\" ] && [ \"$2\" = \"--short\" ] && [ \"$3\" = \"HEAD\" ]; then printf 'abc123\\n'; exit 0; fi\nexit 1\n",
1025 );
1026
1027 let original_home = std::env::var_os("HOME");
1028 let original_path = std::env::var_os("PATH");
1029 let original_zdotdir = std::env::var_os("ZDOTDIR");
1030 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1031 unsafe {
1032 std::env::set_var("HOME", &home_dir);
1033 std::env::remove_var("ZDOTDIR");
1034 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1035 std::env::remove_var("TASKERS_SHELL_PROFILE");
1036 std::env::set_var(
1037 "PATH",
1038 format!(
1039 "{}:{}",
1040 real_bin_dir.display(),
1041 original_path
1042 .as_deref()
1043 .map(|value| value.to_string_lossy().into_owned())
1044 .unwrap_or_default()
1045 ),
1046 );
1047 }
1048
1049 let integration = ShellIntegration {
1050 root: runtime_root.clone(),
1051 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1052 real_shell: zsh_path,
1053 };
1054 let mut launch = integration.launch_spec();
1055 launch.env.insert(
1056 "TASKERS_CTL_PATH".into(),
1057 taskersctl_path.display().to_string(),
1058 );
1059 launch
1060 .env
1061 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1062 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1063 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1064 launch
1065 .env
1066 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1067
1068 let mut spec = CommandSpec::new(launch.program.display().to_string());
1069 spec.args = launch.args;
1070 spec.env = launch.env;
1071 spec.cwd = Some(repo_dir.clone());
1072
1073 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1074 std::thread::sleep(Duration::from_millis(250));
1075 spawned.session.write_all(b"exit\n").expect("exit shell");
1076
1077 let mut reader = spawned.reader;
1078 let mut buffer = [0u8; 1024];
1079 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1080
1081 let log = fs::read_to_string(&test_log).expect("read metadata log");
1082 assert!(
1083 log.contains("signal --source shell --kind metadata"),
1084 "expected zsh shell to emit metadata, got: {log}"
1085 );
1086 assert!(
1087 log.contains(&format!("--cwd {}", repo_dir.display())),
1088 "expected metadata cwd in log, got: {log}"
1089 );
1090 assert!(
1091 log.contains("--repo repo"),
1092 "expected repo name in log, got: {log}"
1093 );
1094 assert!(
1095 log.contains("--branch main"),
1096 "expected git branch in log, got: {log}"
1097 );
1098
1099 restore_env_var("HOME", original_home);
1100 restore_env_var("PATH", original_path);
1101 restore_env_var("ZDOTDIR", original_zdotdir);
1102 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1103 }
1104
1105 #[test]
1106 fn embedded_zsh_falls_back_to_jj_branch_when_git_probe_fails() {
1107 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1108 let runtime_root = unique_temp_dir("taskers-runtime-zsh-jj-fallback");
1109 install_runtime_assets(&runtime_root).expect("install runtime assets");
1110
1111 let home_dir = runtime_root.join("home");
1112 let real_bin_dir = runtime_root.join("real-bin");
1113 let repo_dir = runtime_root.join("repo");
1114 fs::create_dir_all(&home_dir).expect("home dir");
1115 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1116 fs::create_dir_all(&repo_dir).expect("repo dir");
1117
1118 let taskersctl_path = runtime_root.join("taskersctl");
1119 let test_log = runtime_root.join("taskersctl.log");
1120 write_executable(
1121 &taskersctl_path,
1122 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1123 );
1124 write_executable(&real_bin_dir.join("git"), "#!/bin/sh\nexit 1\n");
1125 write_executable(
1126 &real_bin_dir.join("jj"),
1127 &format!(
1128 "#!/bin/sh\nif [ \"$1\" = \"root\" ]; then printf '%s\\n' \"{}\"; exit 0; fi\nif [ \"$1\" = \"log\" ]; then printf 'jj123456\\n'; exit 0; fi\nexit 1\n",
1129 repo_dir.display()
1130 ),
1131 );
1132
1133 let original_home = std::env::var_os("HOME");
1134 let original_path = std::env::var_os("PATH");
1135 let original_zdotdir = std::env::var_os("ZDOTDIR");
1136 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1137 unsafe {
1138 std::env::set_var("HOME", &home_dir);
1139 std::env::remove_var("ZDOTDIR");
1140 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1141 std::env::remove_var("TASKERS_SHELL_PROFILE");
1142 std::env::set_var(
1143 "PATH",
1144 format!(
1145 "{}:{}",
1146 real_bin_dir.display(),
1147 original_path
1148 .as_deref()
1149 .map(|value| value.to_string_lossy().into_owned())
1150 .unwrap_or_default()
1151 ),
1152 );
1153 }
1154
1155 let integration = ShellIntegration {
1156 root: runtime_root.clone(),
1157 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1158 real_shell: zsh_path,
1159 };
1160 let mut launch = integration.launch_spec();
1161 launch.env.insert(
1162 "TASKERS_CTL_PATH".into(),
1163 taskersctl_path.display().to_string(),
1164 );
1165 launch
1166 .env
1167 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1168 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1169 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1170 launch
1171 .env
1172 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1173
1174 let mut spec = CommandSpec::new(launch.program.display().to_string());
1175 spec.args = launch.args;
1176 spec.env = launch.env;
1177 spec.cwd = Some(repo_dir.clone());
1178
1179 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1180 std::thread::sleep(Duration::from_millis(250));
1181 spawned.session.write_all(b"exit\n").expect("exit shell");
1182
1183 let mut reader = spawned.reader;
1184 let mut buffer = [0u8; 1024];
1185 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1186
1187 let log = fs::read_to_string(&test_log).expect("read metadata log");
1188 assert!(
1189 log.contains("signal --source shell --kind metadata"),
1190 "expected zsh shell to emit metadata, got: {log}"
1191 );
1192 assert!(
1193 log.contains(&format!("--cwd {}", repo_dir.display())),
1194 "expected metadata cwd in log, got: {log}"
1195 );
1196 assert!(
1197 log.contains("--repo repo"),
1198 "expected repo name in log, got: {log}"
1199 );
1200 assert!(
1201 log.contains("--branch jj123456"),
1202 "expected JJ branch fallback in log, got: {log}"
1203 );
1204
1205 restore_env_var("HOME", original_home);
1206 restore_env_var("PATH", original_path);
1207 restore_env_var("ZDOTDIR", original_zdotdir);
1208 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1209 }
1210
1211 #[test]
1212 fn bash_shell_hook_marks_prompt_only_once_until_preexec_runs() {
1213 let bash_hooks = include_str!(concat!(
1214 env!("CARGO_MANIFEST_DIR"),
1215 "/assets/shell/taskers-hooks.bash"
1216 ));
1217
1218 assert!(
1219 bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED"),
1220 "expected bash hooks to track whether the prompt is already OSC133-marked"
1221 );
1222 assert!(
1223 bash_hooks.contains("TASKERS_OSC133_SAVE_PS1:-"),
1224 "expected bash hooks to treat saved prompt copies as part of the marked state"
1225 );
1226 assert!(
1227 bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED=1"),
1228 "expected bash hooks to keep the marked state synchronized with the prompt save guards"
1229 );
1230 }
1231
1232 #[test]
1233 fn shell_hooks_invalidate_metadata_cache_after_agent_exit() {
1234 let bash_hooks = include_str!(concat!(
1235 env!("CARGO_MANIFEST_DIR"),
1236 "/assets/shell/taskers-hooks.bash"
1237 ));
1238 let zsh_hooks = include_str!(concat!(
1239 env!("CARGO_MANIFEST_DIR"),
1240 "/assets/shell/taskers-hooks.zsh"
1241 ));
1242 let fish_hooks = include_str!(concat!(
1243 env!("CARGO_MANIFEST_DIR"),
1244 "/assets/shell/taskers-hooks.fish"
1245 ));
1246
1247 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1248 assert!(
1249 asset.contains("TASKERS_LAST_META_AGENT"),
1250 "expected hook asset to invalidate cached agent metadata after exit"
1251 );
1252 assert!(
1253 asset.contains("TASKERS_LAST_META_AGENT_ACTIVE"),
1254 "expected hook asset to invalidate cached agent-active metadata after exit"
1255 );
1256 }
1257 }
1258
1259 #[test]
1260 fn agent_proxy_owns_explicit_surface_agent_lifecycle_commands() {
1261 let bash_hooks = include_str!(concat!(
1262 env!("CARGO_MANIFEST_DIR"),
1263 "/assets/shell/taskers-hooks.bash"
1264 ));
1265 let zsh_hooks = include_str!(concat!(
1266 env!("CARGO_MANIFEST_DIR"),
1267 "/assets/shell/taskers-hooks.zsh"
1268 ));
1269 let fish_hooks = include_str!(concat!(
1270 env!("CARGO_MANIFEST_DIR"),
1271 "/assets/shell/taskers-hooks.fish"
1272 ));
1273 let agent_proxy = include_str!(concat!(
1274 env!("CARGO_MANIFEST_DIR"),
1275 "/assets/shell/taskers-agent-proxy.sh"
1276 ));
1277
1278 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1279 assert!(
1280 !asset.contains("surface agent-start"),
1281 "expected hook asset to leave explicit lifecycle start to the proxy"
1282 );
1283 assert!(
1284 !asset.contains("surface agent-stop"),
1285 "expected hook asset to leave explicit lifecycle stop to the proxy"
1286 );
1287 }
1288 assert!(
1289 agent_proxy.contains("surface agent-start"),
1290 "expected proxy asset to emit explicit surface agent start commands"
1291 );
1292 assert!(
1293 agent_proxy.contains("surface agent-stop"),
1294 "expected proxy asset to emit explicit surface agent stop commands"
1295 );
1296 }
1297
1298 #[test]
1299 fn embedded_zsh_codex_command_emits_surface_lifecycle_via_proxy() {
1300 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1301 let runtime_root = unique_temp_dir("taskers-runtime-proxy-clean");
1302 install_runtime_assets(&runtime_root).expect("install runtime assets");
1303 super::install_agent_shims(&runtime_root).expect("install agent shims");
1304
1305 let home_dir = runtime_root.join("home");
1306 let real_bin_dir = runtime_root.join("real-bin");
1307 fs::create_dir_all(&home_dir).expect("home dir");
1308 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1309
1310 let taskersctl_path = runtime_root.join("taskersctl");
1311 let args_log = runtime_root.join("codex-args.log");
1312 write_executable(
1313 &taskersctl_path,
1314 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1315 );
1316 write_executable(
1317 &real_bin_dir.join("codex"),
1318 "#!/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",
1319 );
1320
1321 let original_home = std::env::var_os("HOME");
1322 let original_path = std::env::var_os("PATH");
1323 let original_zdotdir = std::env::var_os("ZDOTDIR");
1324 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1325 let test_log = runtime_root.join("taskersctl.log");
1326 unsafe {
1327 std::env::set_var("HOME", &home_dir);
1328 std::env::remove_var("ZDOTDIR");
1329 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1330 std::env::remove_var("TASKERS_SHELL_PROFILE");
1331 std::env::set_var(
1332 "PATH",
1333 format!(
1334 "{}:{}",
1335 real_bin_dir.display(),
1336 original_path
1337 .as_deref()
1338 .map(|value| value.to_string_lossy().into_owned())
1339 .unwrap_or_default()
1340 ),
1341 );
1342 }
1343
1344 let integration = ShellIntegration {
1345 root: runtime_root.clone(),
1346 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1347 real_shell: zsh_path,
1348 };
1349 let mut launch = integration.launch_spec();
1350 launch.args.push("-c".into());
1351 launch.args.push("codex".into());
1352 launch.env.insert(
1353 "TASKERS_CTL_PATH".into(),
1354 taskersctl_path.display().to_string(),
1355 );
1356 launch
1357 .env
1358 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1359 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1360 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1361 launch
1362 .env
1363 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1364 launch
1365 .env
1366 .insert("FAKE_CODEX_ARGS_LOG".into(), args_log.display().to_string());
1367
1368 let mut spec = CommandSpec::new(launch.program.display().to_string());
1369 spec.args = launch.args;
1370 spec.env = launch.env;
1371
1372 let spawned = PtySession::spawn(&spec).expect("spawn shell");
1373 let mut reader = spawned.reader;
1374 let mut buffer = [0u8; 1024];
1375 let mut output = String::new();
1376 loop {
1377 let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1378 if bytes_read == 0 {
1379 break;
1380 }
1381 output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1382 }
1383
1384 let log = fs::read_to_string(&test_log)
1385 .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1386 let codex_args = fs::read_to_string(&args_log)
1387 .unwrap_or_else(|error| panic!("read codex args log failed: {error}; output={output}"));
1388 assert!(
1389 codex_args.contains("-c\n") || codex_args.contains("-c "),
1390 "expected codex wrapper to inject config override, got: {codex_args}"
1391 );
1392 assert!(
1393 codex_args.contains("notify=[\"bash\",\""),
1394 "expected codex wrapper to inject notify helper override, got: {codex_args}"
1395 );
1396 assert!(
1397 log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1398 "expected start lifecycle in log, got: {log}"
1399 );
1400 assert!(
1401 log.contains("agent-hook stop --workspace ws --pane pn --surface sf --agent codex --title Codex --message Turn complete"),
1402 "expected codex notify helper to emit stop hook, got: {log}"
1403 );
1404 assert!(
1405 log.contains(
1406 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1407 ),
1408 "expected stop lifecycle in log, got: {log}"
1409 );
1410
1411 restore_env_var("HOME", original_home);
1412 restore_env_var("PATH", original_path);
1413 restore_env_var("ZDOTDIR", original_zdotdir);
1414 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1415 }
1416
1417 #[test]
1418 fn embedded_zsh_claude_command_injects_taskers_hooks_and_process_lifecycle() {
1419 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1420 let runtime_root = unique_temp_dir("taskers-runtime-proxy-claude");
1421 install_runtime_assets(&runtime_root).expect("install runtime assets");
1422 super::install_agent_shims(&runtime_root).expect("install agent shims");
1423
1424 let home_dir = runtime_root.join("home");
1425 let real_bin_dir = runtime_root.join("real-bin");
1426 fs::create_dir_all(&home_dir).expect("home dir");
1427 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1428
1429 let taskersctl_path = runtime_root.join("taskersctl");
1430 let args_log = runtime_root.join("claude-args.log");
1431 write_executable(
1432 &taskersctl_path,
1433 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1434 );
1435 write_executable(
1436 &real_bin_dir.join("claude"),
1437 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CLAUDE_ARGS_LOG\"\nexit 0\n",
1438 );
1439
1440 let original_home = std::env::var_os("HOME");
1441 let original_path = std::env::var_os("PATH");
1442 let original_zdotdir = std::env::var_os("ZDOTDIR");
1443 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1444 let test_log = runtime_root.join("taskersctl.log");
1445 unsafe {
1446 std::env::set_var("HOME", &home_dir);
1447 std::env::remove_var("ZDOTDIR");
1448 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1449 std::env::remove_var("TASKERS_SHELL_PROFILE");
1450 std::env::set_var(
1451 "PATH",
1452 format!(
1453 "{}:{}",
1454 real_bin_dir.display(),
1455 original_path
1456 .as_deref()
1457 .map(|value| value.to_string_lossy().into_owned())
1458 .unwrap_or_default()
1459 ),
1460 );
1461 }
1462
1463 let integration = ShellIntegration {
1464 root: runtime_root.clone(),
1465 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1466 real_shell: zsh_path,
1467 };
1468 let mut launch = integration.launch_spec();
1469 launch.args.push("-c".into());
1470 launch.args.push("claude --help".into());
1471 launch.env.insert(
1472 "TASKERS_CTL_PATH".into(),
1473 taskersctl_path.display().to_string(),
1474 );
1475 launch
1476 .env
1477 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1478 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1479 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1480 launch
1481 .env
1482 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1483 launch.env.insert(
1484 "FAKE_CLAUDE_ARGS_LOG".into(),
1485 args_log.display().to_string(),
1486 );
1487
1488 let mut spec = CommandSpec::new(launch.program.display().to_string());
1489 spec.args = launch.args;
1490 spec.env = launch.env;
1491
1492 let spawned = PtySession::spawn(&spec).expect("spawn shell");
1493 let mut reader = spawned.reader;
1494 let mut buffer = [0u8; 1024];
1495 let mut output = String::new();
1496 loop {
1497 let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1498 if bytes_read == 0 {
1499 break;
1500 }
1501 output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1502 }
1503
1504 let log = fs::read_to_string(&test_log)
1505 .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1506 let claude_args = fs::read_to_string(&args_log).unwrap_or_else(|error| {
1507 panic!("read claude args log failed: {error}; output={output}")
1508 });
1509 let hook_path = runtime_root.join("taskers-claude-hook.sh");
1510 assert!(
1511 claude_args.contains("--settings"),
1512 "expected claude wrapper to inject hook settings, got: {claude_args}"
1513 );
1514 assert!(
1515 claude_args.contains(&hook_path.display().to_string())
1516 && claude_args.contains("user-prompt-submit"),
1517 "expected claude wrapper to inject prompt-submit hook path, got: {claude_args}"
1518 );
1519 assert!(
1520 claude_args.contains(&hook_path.display().to_string()) && claude_args.contains("stop"),
1521 "expected claude wrapper to inject stop hook path, got: {claude_args}"
1522 );
1523 assert!(
1524 log.contains(
1525 "surface agent-start --workspace ws --pane pn --surface sf --agent claude"
1526 ),
1527 "expected start lifecycle in log, got: {log}"
1528 );
1529 assert!(
1530 log.contains(
1531 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1532 ),
1533 "expected stop lifecycle in log, got: {log}"
1534 );
1535
1536 restore_env_var("HOME", original_home);
1537 restore_env_var("PATH", original_path);
1538 restore_env_var("ZDOTDIR", original_zdotdir);
1539 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1540 }
1541
1542 #[test]
1543 fn claude_code_shim_preserves_binary_lookup_and_quotes_hook_paths() {
1544 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1545 let runtime_root = unique_temp_dir("taskers runtime claude code");
1546 install_runtime_assets(&runtime_root).expect("install runtime assets");
1547 super::install_agent_shims(&runtime_root).expect("install agent shims");
1548
1549 let real_bin_dir = runtime_root.join("real-bin");
1550 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1551
1552 let capture_path = runtime_root.join("claude-code-capture.log");
1553 write_executable(
1554 &real_bin_dir.join("claude-code"),
1555 "#!/bin/sh\nprintf 'target=%s\\n' \"${TASKERS_AGENT_PROXY_TARGET:-}\" >> \"$FAKE_CLAUDE_CAPTURE\"\nprintf '%s\\n' \"$@\" >> \"$FAKE_CLAUDE_CAPTURE\"\nexit 0\n",
1556 );
1557
1558 let original_path = std::env::var_os("PATH");
1559 let shim_path = runtime_root.join("bin").join("claude-code");
1560 let output = Command::new(&shim_path)
1561 .env(
1562 "PATH",
1563 format!(
1564 "{}:{}",
1565 real_bin_dir.display(),
1566 original_path
1567 .as_deref()
1568 .map(|value| value.to_string_lossy().into_owned())
1569 .unwrap_or_default()
1570 ),
1571 )
1572 .env("FAKE_CLAUDE_CAPTURE", &capture_path)
1573 .arg("--help")
1574 .output()
1575 .expect("run claude-code shim");
1576
1577 assert!(
1578 output.status.success(),
1579 "expected claude-code shim to succeed, stdout={}, stderr={}",
1580 String::from_utf8_lossy(&output.stdout),
1581 String::from_utf8_lossy(&output.stderr)
1582 );
1583
1584 let capture = fs::read_to_string(&capture_path).expect("read capture log");
1585 let hook_path = runtime_root.join("taskers-claude-hook.sh");
1586 assert!(
1587 capture.contains("target=claude-code"),
1588 "expected shim to preserve the invoked claude-code lookup target, got: {capture}"
1589 );
1590 assert!(
1591 capture.contains("--settings"),
1592 "expected claude-code shim to forward hook settings, got: {capture}"
1593 );
1594 assert!(
1595 capture.contains(&format!("'{}' user-prompt-submit", hook_path.display())),
1596 "expected claude-code hook path to be single-quoted inside settings, got: {capture}"
1597 );
1598
1599 restore_env_var("PATH", original_path);
1600 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1601 }
1602
1603 #[test]
1604 fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() {
1605 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1606 let runtime_root = unique_temp_dir("taskers-runtime-proxy-interrupt");
1607 install_runtime_assets(&runtime_root).expect("install runtime assets");
1608 super::install_agent_shims(&runtime_root).expect("install agent shims");
1609
1610 let home_dir = runtime_root.join("home");
1611 let real_bin_dir = runtime_root.join("real-bin");
1612 fs::create_dir_all(&home_dir).expect("home dir");
1613 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1614
1615 let taskersctl_path = runtime_root.join("taskersctl");
1616 write_executable(
1617 &taskersctl_path,
1618 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1619 );
1620 write_executable(
1621 &real_bin_dir.join("codex"),
1622 "#!/bin/sh\ntrap 'exit 130' INT\nwhile :; do sleep 1; done\n",
1623 );
1624
1625 let original_home = std::env::var_os("HOME");
1626 let original_path = std::env::var_os("PATH");
1627 let original_zdotdir = std::env::var_os("ZDOTDIR");
1628 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1629 let test_log = runtime_root.join("taskersctl.log");
1630 unsafe {
1631 std::env::set_var("HOME", &home_dir);
1632 std::env::remove_var("ZDOTDIR");
1633 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1634 std::env::remove_var("TASKERS_SHELL_PROFILE");
1635 std::env::set_var(
1636 "PATH",
1637 format!(
1638 "{}:{}",
1639 real_bin_dir.display(),
1640 original_path
1641 .as_deref()
1642 .map(|value| value.to_string_lossy().into_owned())
1643 .unwrap_or_default()
1644 ),
1645 );
1646 }
1647
1648 let integration = ShellIntegration {
1649 root: runtime_root.clone(),
1650 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1651 real_shell: zsh_path,
1652 };
1653 let mut launch = integration.launch_spec();
1654 launch.args.push("-c".into());
1655 launch.args.push("codex".into());
1656 launch.env.insert(
1657 "TASKERS_CTL_PATH".into(),
1658 taskersctl_path.display().to_string(),
1659 );
1660 launch
1661 .env
1662 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1663 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1664 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1665 launch
1666 .env
1667 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1668
1669 let mut spec = CommandSpec::new(launch.program.display().to_string());
1670 spec.args = launch.args;
1671 spec.env = launch.env;
1672
1673 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1674 std::thread::sleep(Duration::from_millis(250));
1675 spawned
1676 .session
1677 .write_all(b"\x03")
1678 .expect("send ctrl-c to shell");
1679
1680 let mut reader = spawned.reader;
1681 let mut buffer = [0u8; 1024];
1682 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1683
1684 let log = fs::read_to_string(&test_log).expect("read lifecycle log");
1685 assert!(
1686 log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1687 "expected start lifecycle in log, got: {log}"
1688 );
1689 assert!(
1690 log.contains(
1691 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 130"
1692 ),
1693 "expected interrupted stop lifecycle in log, got: {log}"
1694 );
1695
1696 restore_env_var("HOME", original_home);
1697 restore_env_var("PATH", original_path);
1698 restore_env_var("ZDOTDIR", original_zdotdir);
1699 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1700 }
1701
1702 fn unique_temp_dir(prefix: &str) -> PathBuf {
1703 let unique = SystemTime::now()
1704 .duration_since(SystemTime::UNIX_EPOCH)
1705 .expect("time")
1706 .as_nanos();
1707 std::env::temp_dir().join(format!("{prefix}-{unique}"))
1708 }
1709
1710 fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1711 unsafe {
1712 match value {
1713 Some(value) => std::env::set_var(key, value),
1714 None => std::env::remove_var(key),
1715 }
1716 }
1717 }
1718
1719 fn write_executable(path: &PathBuf, content: &str) {
1720 fs::write(path, content).expect("write script");
1721 #[cfg(unix)]
1722 {
1723 let mut permissions = fs::metadata(path).expect("metadata").permissions();
1724 permissions.set_mode(0o755);
1725 fs::set_permissions(path, permissions).expect("chmod script");
1726 }
1727 }
1728}