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 &root.join("taskers-hooks.zsh"),
526 include_str!(concat!(
527 env!("CARGO_MANIFEST_DIR"),
528 "/assets/shell/taskers-hooks.zsh"
529 )),
530 false,
531 )?;
532 write_asset(
533 &zsh_runtime_dir(root).join(".zshenv"),
534 include_str!(concat!(
535 env!("CARGO_MANIFEST_DIR"),
536 "/assets/shell/zsh/.zshenv"
537 )),
538 false,
539 )?;
540 write_asset(
541 &zsh_runtime_dir(root).join(".zshrc"),
542 include_str!(concat!(
543 env!("CARGO_MANIFEST_DIR"),
544 "/assets/shell/zsh/.zshrc"
545 )),
546 false,
547 )?;
548 write_asset(
549 &zsh_runtime_dir(root).join(".zcompdump"),
550 include_str!(concat!(
551 env!("CARGO_MANIFEST_DIR"),
552 "/assets/shell/zsh/.zcompdump"
553 )),
554 false,
555 )?;
556 write_asset(
557 &root.join("taskers-codex-notify.sh"),
558 include_str!(concat!(
559 env!("CARGO_MANIFEST_DIR"),
560 "/assets/shell/taskers-codex-notify.sh"
561 )),
562 true,
563 )?;
564 write_asset(
565 &root.join("taskers-claude-hook.sh"),
566 include_str!(concat!(
567 env!("CARGO_MANIFEST_DIR"),
568 "/assets/shell/taskers-claude-hook.sh"
569 )),
570 true,
571 )?;
572 write_asset(
573 &root.join("taskers-agent-codex.sh"),
574 include_str!(concat!(
575 env!("CARGO_MANIFEST_DIR"),
576 "/assets/shell/taskers-agent-codex.sh"
577 )),
578 true,
579 )?;
580 write_asset(
581 &root.join("taskers-agent-claude.sh"),
582 include_str!(concat!(
583 env!("CARGO_MANIFEST_DIR"),
584 "/assets/shell/taskers-agent-claude.sh"
585 )),
586 true,
587 )?;
588 write_asset(
589 &root.join("taskers-agent-proxy.sh"),
590 include_str!(concat!(
591 env!("CARGO_MANIFEST_DIR"),
592 "/assets/shell/taskers-agent-proxy.sh"
593 )),
594 true,
595 )?;
596 Ok(())
597}
598
599#[cfg(test)]
600mod tests {
601 use std::{
602 fs,
603 path::PathBuf,
604 process::Command,
605 sync::Mutex,
606 time::{Duration, SystemTime},
607 };
608
609 #[cfg(unix)]
610 use std::os::unix::fs::PermissionsExt;
611
612 use super::{
613 INHERITED_TERMINAL_ENV_KEYS, ShellIntegration, expand_home_prefix, fish_source_command,
614 install_runtime_assets, normalize_shell_override, resolve_shell_override, zsh_runtime_dir,
615 };
616 use crate::{CommandSpec, PtySession};
617
618 static ENV_LOCK: Mutex<()> = Mutex::new(());
619
620 #[test]
621 fn shell_override_normalizes_blank_values() {
622 assert_eq!(normalize_shell_override(""), None);
623 assert_eq!(normalize_shell_override(" "), None);
624 assert_eq!(
625 normalize_shell_override(" /usr/bin/fish "),
626 Some("/usr/bin/fish".into())
627 );
628 }
629
630 #[test]
631 fn fish_source_command_uses_runtime_env_path() {
632 assert_eq!(
633 fish_source_command(),
634 r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#
635 );
636 }
637
638 #[test]
639 fn zsh_launch_spec_routes_through_runtime_zdotdir() {
640 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
641 let original_zdotdir = std::env::var_os("ZDOTDIR");
642 let original_home = std::env::var_os("HOME");
643 unsafe {
644 std::env::set_var("HOME", "/tmp/taskers-home");
645 std::env::set_var("ZDOTDIR", "/tmp/user-zdotdir");
646 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
647 std::env::remove_var("TASKERS_SHELL_PROFILE");
648 }
649
650 let integration = ShellIntegration {
651 root: PathBuf::from("/tmp/taskers-runtime"),
652 wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
653 real_shell: PathBuf::from("/usr/bin/zsh"),
654 };
655 let spec = integration.launch_spec();
656
657 assert_eq!(
658 spec.env.get("ZDOTDIR").map(String::as_str),
659 Some("/tmp/taskers-runtime/zsh")
660 );
661 assert_eq!(
662 spec.env.get("TASKERS_USER_ZDOTDIR").map(String::as_str),
663 Some("/tmp/user-zdotdir")
664 );
665 assert_eq!(spec.program, integration.wrapper_path);
666 assert_eq!(spec.args, vec!["-i"]);
667
668 unsafe {
669 match original_zdotdir {
670 Some(value) => std::env::set_var("ZDOTDIR", value),
671 None => std::env::remove_var("ZDOTDIR"),
672 }
673 match original_home {
674 Some(value) => std::env::set_var("HOME", value),
675 None => std::env::remove_var("HOME"),
676 }
677 }
678 }
679
680 #[test]
681 fn zsh_runtime_dir_is_nested_under_runtime_root() {
682 assert_eq!(
683 zsh_runtime_dir(&PathBuf::from("/tmp/taskers-runtime")),
684 PathBuf::from("/tmp/taskers-runtime/zsh")
685 );
686 }
687
688 #[test]
689 fn install_runtime_assets_writes_zsh_runtime_files() {
690 let root = unique_temp_dir("taskers-runtime-test");
691 install_runtime_assets(&root).expect("install runtime assets");
692
693 assert!(root.join("taskers-shell-wrapper.sh").is_file());
694 assert!(root.join("taskers-hooks.bash").is_file());
695 assert!(root.join("taskers-hooks.fish").is_file());
696 assert!(root.join("taskers-hooks.zsh").is_file());
697 assert!(root.join("taskers-codex-notify.sh").is_file());
698 assert!(root.join("taskers-claude-hook.sh").is_file());
699 assert!(root.join("taskers-agent-codex.sh").is_file());
700 assert!(root.join("taskers-agent-claude.sh").is_file());
701 assert!(root.join("taskers-agent-proxy.sh").is_file());
702 assert!(zsh_runtime_dir(&root).join(".zshenv").is_file());
703 assert!(zsh_runtime_dir(&root).join(".zshrc").is_file());
704 assert!(zsh_runtime_dir(&root).join(".zcompdump").is_file());
705
706 fs::remove_dir_all(&root).expect("cleanup runtime assets");
707 }
708
709 #[test]
710 fn home_prefix_expansion_without_home_keeps_original_shape() {
711 let original = "~/bin/fish";
712 let expanded = expand_home_prefix(original);
713 if std::env::var_os("HOME").is_some() {
714 assert_ne!(expanded, original);
715 } else {
716 assert_eq!(expanded, original);
717 }
718 }
719
720 #[test]
721 fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() {
722 for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] {
723 assert!(
724 INHERITED_TERMINAL_ENV_KEYS.contains(&key),
725 "expected {key} to be scrubbed from inherited terminal env"
726 );
727 }
728 }
729
730 #[test]
731 fn shell_wrapper_exports_taskers_tty_name() {
732 let wrapper = include_str!(concat!(
733 env!("CARGO_MANIFEST_DIR"),
734 "/assets/shell/taskers-shell-wrapper.sh"
735 ));
736 assert!(
737 wrapper.contains("TASKERS_TTY_NAME"),
738 "expected wrapper to export TASKERS_TTY_NAME"
739 );
740 }
741
742 #[test]
743 fn shell_wrapper_routes_terminal_sessions_through_sidecar_attach() {
744 let wrapper = include_str!(concat!(
745 env!("CARGO_MANIFEST_DIR"),
746 "/assets/shell/taskers-shell-wrapper.sh"
747 ));
748 assert!(
749 wrapper.contains("TASKERS_TERMINAL_SOCKET"),
750 "expected wrapper to branch on terminal socket availability"
751 );
752 assert!(
753 wrapper.contains("TASKERS_TERMINAL_SESSION_ID"),
754 "expected wrapper to require terminal session ids for session attach"
755 );
756 assert!(
757 wrapper.contains("session attach"),
758 "expected wrapper to delegate continuity startup to taskersctl session attach"
759 );
760 }
761
762 #[test]
763 fn shell_hooks_and_proxy_require_surface_tty_identity() {
764 let bash_hooks = include_str!(concat!(
765 env!("CARGO_MANIFEST_DIR"),
766 "/assets/shell/taskers-hooks.bash"
767 ));
768 let zsh_hooks = include_str!(concat!(
769 env!("CARGO_MANIFEST_DIR"),
770 "/assets/shell/taskers-hooks.zsh"
771 ));
772 let fish_hooks = include_str!(concat!(
773 env!("CARGO_MANIFEST_DIR"),
774 "/assets/shell/taskers-hooks.fish"
775 ));
776 let agent_proxy = include_str!(concat!(
777 env!("CARGO_MANIFEST_DIR"),
778 "/assets/shell/taskers-agent-proxy.sh"
779 ));
780
781 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
782 assert!(
783 asset.contains("TASKERS_SURFACE_ID"),
784 "expected asset to require TASKERS_SURFACE_ID"
785 );
786 assert!(
787 asset.contains("TASKERS_TTY_NAME"),
788 "expected asset to require TASKERS_TTY_NAME"
789 );
790 }
791 assert!(
792 agent_proxy.contains("TASKERS_AGENT_PROXY_ACTIVE"),
793 "expected proxy asset to keep loop-prevention guard"
794 );
795 }
796
797 #[test]
798 fn shell_hooks_only_treat_agent_identity_as_live_process_state() {
799 let bash_hooks = include_str!(concat!(
800 env!("CARGO_MANIFEST_DIR"),
801 "/assets/shell/taskers-hooks.bash"
802 ));
803 let zsh_hooks = include_str!(concat!(
804 env!("CARGO_MANIFEST_DIR"),
805 "/assets/shell/taskers-hooks.zsh"
806 ));
807 let fish_hooks = include_str!(concat!(
808 env!("CARGO_MANIFEST_DIR"),
809 "/assets/shell/taskers-hooks.fish"
810 ));
811
812 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
813 assert!(
814 !asset.contains("TASKERS_PANE_AGENT_KIND"),
815 "expected hook asset to avoid sticky pane-level agent identity"
816 );
817 }
818 }
819
820 #[test]
821 fn shell_assets_do_not_auto_report_completed_on_clean_or_interrupted_exit() {
822 let bash_hooks = include_str!(concat!(
823 env!("CARGO_MANIFEST_DIR"),
824 "/assets/shell/taskers-hooks.bash"
825 ));
826 let zsh_hooks = include_str!(concat!(
827 env!("CARGO_MANIFEST_DIR"),
828 "/assets/shell/taskers-hooks.zsh"
829 ));
830 let fish_hooks = include_str!(concat!(
831 env!("CARGO_MANIFEST_DIR"),
832 "/assets/shell/taskers-hooks.fish"
833 ));
834 let agent_proxy = include_str!(concat!(
835 env!("CARGO_MANIFEST_DIR"),
836 "/assets/shell/taskers-agent-proxy.sh"
837 ));
838
839 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
840 assert!(
841 !asset.contains("taskers__emit_with_metadata completed"),
842 "expected hook asset to avoid auto-emitting completed on bare agent exit"
843 );
844 }
845 assert!(
846 !agent_proxy.contains("emit_signal completed"),
847 "expected proxy to avoid auto-emitting completed on bare agent exit"
848 );
849 assert!(
850 !agent_proxy.contains("emit_signal error"),
851 "expected proxy to avoid owning stop/error signaling"
852 );
853 }
854
855 #[test]
856 fn zsh_shell_hook_avoids_readonly_status_parameter_name() {
857 let zsh_hooks = include_str!(concat!(
858 env!("CARGO_MANIFEST_DIR"),
859 "/assets/shell/taskers-hooks.zsh"
860 ));
861
862 assert!(
863 !zsh_hooks.contains("local status="),
864 "expected zsh hooks to avoid assigning to zsh's readonly status parameter"
865 );
866 }
867
868 #[test]
869 fn zsh_shell_hook_tracks_directory_changes_for_metadata() {
870 let zsh_hooks = include_str!(concat!(
871 env!("CARGO_MANIFEST_DIR"),
872 "/assets/shell/taskers-hooks.zsh"
873 ));
874
875 assert!(
876 zsh_hooks.contains("add-zsh-hook chpwd taskers__on_chpwd")
877 || zsh_hooks.contains("chpwd_functions+=(taskers__on_chpwd)"),
878 "expected zsh hooks to refresh metadata on directory changes"
879 );
880 }
881
882 #[test]
883 fn zsh_shell_hook_prefers_shell_tty_and_supports_jj_repos() {
884 let zsh_hooks = include_str!(concat!(
885 env!("CARGO_MANIFEST_DIR"),
886 "/assets/shell/taskers-hooks.zsh"
887 ));
888
889 assert!(
890 zsh_hooks.contains("local current_tty=${TTY:-}"),
891 "expected zsh hooks to prefer zsh's built-in TTY variable"
892 );
893 assert!(
894 zsh_hooks.contains("jj root"),
895 "expected zsh hooks to support JJ repo root detection"
896 );
897 }
898
899 #[test]
900 fn shell_hooks_emit_boolean_agent_active_flags() {
901 let bash_hooks = include_str!(concat!(
902 env!("CARGO_MANIFEST_DIR"),
903 "/assets/shell/taskers-hooks.bash"
904 ));
905 let zsh_hooks = include_str!(concat!(
906 env!("CARGO_MANIFEST_DIR"),
907 "/assets/shell/taskers-hooks.zsh"
908 ));
909 let fish_hooks = include_str!(concat!(
910 env!("CARGO_MANIFEST_DIR"),
911 "/assets/shell/taskers-hooks.fish"
912 ));
913
914 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
915 assert!(
916 asset.contains("true"),
917 "expected hook asset to emit literal boolean true"
918 );
919 assert!(
920 asset.contains("false"),
921 "expected hook asset to emit literal boolean false"
922 );
923 }
924 }
925
926 #[test]
927 fn embedded_zsh_emits_metadata_for_repo_cwd() {
928 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
929 let runtime_root = unique_temp_dir("taskers-runtime-zsh-metadata");
930 install_runtime_assets(&runtime_root).expect("install runtime assets");
931
932 let home_dir = runtime_root.join("home");
933 let real_bin_dir = runtime_root.join("real-bin");
934 let repo_dir = runtime_root.join("repo");
935 fs::create_dir_all(&home_dir).expect("home dir");
936 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
937 fs::create_dir_all(&repo_dir).expect("repo dir");
938
939 let taskersctl_path = runtime_root.join("taskersctl");
940 let test_log = runtime_root.join("taskersctl.log");
941 write_executable(
942 &taskersctl_path,
943 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
944 );
945 write_executable(
946 &real_bin_dir.join("git"),
947 "#!/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",
948 );
949
950 let original_home = std::env::var_os("HOME");
951 let original_path = std::env::var_os("PATH");
952 let original_zdotdir = std::env::var_os("ZDOTDIR");
953 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
954 unsafe {
955 std::env::set_var("HOME", &home_dir);
956 std::env::remove_var("ZDOTDIR");
957 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
958 std::env::remove_var("TASKERS_SHELL_PROFILE");
959 std::env::set_var(
960 "PATH",
961 format!(
962 "{}:{}",
963 real_bin_dir.display(),
964 original_path
965 .as_deref()
966 .map(|value| value.to_string_lossy().into_owned())
967 .unwrap_or_default()
968 ),
969 );
970 }
971
972 let integration = ShellIntegration {
973 root: runtime_root.clone(),
974 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
975 real_shell: zsh_path,
976 };
977 let mut launch = integration.launch_spec();
978 launch.env.insert(
979 "TASKERS_CTL_PATH".into(),
980 taskersctl_path.display().to_string(),
981 );
982 launch
983 .env
984 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
985 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
986 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
987 launch
988 .env
989 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
990
991 let mut spec = CommandSpec::new(launch.program.display().to_string());
992 spec.args = launch.args;
993 spec.env = launch.env;
994 spec.cwd = Some(repo_dir.clone());
995
996 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
997 std::thread::sleep(Duration::from_millis(250));
998 spawned.session.write_all(b"exit\n").expect("exit shell");
999
1000 let mut reader = spawned.reader;
1001 let mut buffer = [0u8; 1024];
1002 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1003
1004 let log = fs::read_to_string(&test_log).expect("read metadata log");
1005 assert!(
1006 log.contains("signal --source shell --kind metadata"),
1007 "expected zsh shell to emit metadata, got: {log}"
1008 );
1009 assert!(
1010 log.contains(&format!("--cwd {}", repo_dir.display())),
1011 "expected metadata cwd in log, got: {log}"
1012 );
1013 assert!(
1014 log.contains("--repo repo"),
1015 "expected repo name in log, got: {log}"
1016 );
1017 assert!(
1018 log.contains("--branch main"),
1019 "expected git branch in log, got: {log}"
1020 );
1021
1022 restore_env_var("HOME", original_home);
1023 restore_env_var("PATH", original_path);
1024 restore_env_var("ZDOTDIR", original_zdotdir);
1025 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1026 }
1027
1028 #[test]
1029 fn embedded_zsh_falls_back_to_jj_branch_when_git_probe_fails() {
1030 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1031 let runtime_root = unique_temp_dir("taskers-runtime-zsh-jj-fallback");
1032 install_runtime_assets(&runtime_root).expect("install runtime assets");
1033
1034 let home_dir = runtime_root.join("home");
1035 let real_bin_dir = runtime_root.join("real-bin");
1036 let repo_dir = runtime_root.join("repo");
1037 fs::create_dir_all(&home_dir).expect("home dir");
1038 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1039 fs::create_dir_all(&repo_dir).expect("repo dir");
1040
1041 let taskersctl_path = runtime_root.join("taskersctl");
1042 let test_log = runtime_root.join("taskersctl.log");
1043 write_executable(
1044 &taskersctl_path,
1045 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1046 );
1047 write_executable(&real_bin_dir.join("git"), "#!/bin/sh\nexit 1\n");
1048 write_executable(
1049 &real_bin_dir.join("jj"),
1050 &format!(
1051 "#!/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",
1052 repo_dir.display()
1053 ),
1054 );
1055
1056 let original_home = std::env::var_os("HOME");
1057 let original_path = std::env::var_os("PATH");
1058 let original_zdotdir = std::env::var_os("ZDOTDIR");
1059 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1060 unsafe {
1061 std::env::set_var("HOME", &home_dir);
1062 std::env::remove_var("ZDOTDIR");
1063 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1064 std::env::remove_var("TASKERS_SHELL_PROFILE");
1065 std::env::set_var(
1066 "PATH",
1067 format!(
1068 "{}:{}",
1069 real_bin_dir.display(),
1070 original_path
1071 .as_deref()
1072 .map(|value| value.to_string_lossy().into_owned())
1073 .unwrap_or_default()
1074 ),
1075 );
1076 }
1077
1078 let integration = ShellIntegration {
1079 root: runtime_root.clone(),
1080 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1081 real_shell: zsh_path,
1082 };
1083 let mut launch = integration.launch_spec();
1084 launch.env.insert(
1085 "TASKERS_CTL_PATH".into(),
1086 taskersctl_path.display().to_string(),
1087 );
1088 launch
1089 .env
1090 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1091 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1092 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1093 launch
1094 .env
1095 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1096
1097 let mut spec = CommandSpec::new(launch.program.display().to_string());
1098 spec.args = launch.args;
1099 spec.env = launch.env;
1100 spec.cwd = Some(repo_dir.clone());
1101
1102 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1103 std::thread::sleep(Duration::from_millis(250));
1104 spawned.session.write_all(b"exit\n").expect("exit shell");
1105
1106 let mut reader = spawned.reader;
1107 let mut buffer = [0u8; 1024];
1108 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1109
1110 let log = fs::read_to_string(&test_log).expect("read metadata log");
1111 assert!(
1112 log.contains("signal --source shell --kind metadata"),
1113 "expected zsh shell to emit metadata, got: {log}"
1114 );
1115 assert!(
1116 log.contains(&format!("--cwd {}", repo_dir.display())),
1117 "expected metadata cwd in log, got: {log}"
1118 );
1119 assert!(
1120 log.contains("--repo repo"),
1121 "expected repo name in log, got: {log}"
1122 );
1123 assert!(
1124 log.contains("--branch jj123456"),
1125 "expected JJ branch fallback in log, got: {log}"
1126 );
1127
1128 restore_env_var("HOME", original_home);
1129 restore_env_var("PATH", original_path);
1130 restore_env_var("ZDOTDIR", original_zdotdir);
1131 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1132 }
1133
1134 #[test]
1135 fn bash_shell_hook_marks_prompt_only_once_until_preexec_runs() {
1136 let bash_hooks = include_str!(concat!(
1137 env!("CARGO_MANIFEST_DIR"),
1138 "/assets/shell/taskers-hooks.bash"
1139 ));
1140
1141 assert!(
1142 bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED"),
1143 "expected bash hooks to track whether the prompt is already OSC133-marked"
1144 );
1145 assert!(
1146 bash_hooks.contains("TASKERS_OSC133_SAVE_PS1:-"),
1147 "expected bash hooks to treat saved prompt copies as part of the marked state"
1148 );
1149 assert!(
1150 bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED=1"),
1151 "expected bash hooks to keep the marked state synchronized with the prompt save guards"
1152 );
1153 }
1154
1155 #[test]
1156 fn shell_hooks_invalidate_metadata_cache_after_agent_exit() {
1157 let bash_hooks = include_str!(concat!(
1158 env!("CARGO_MANIFEST_DIR"),
1159 "/assets/shell/taskers-hooks.bash"
1160 ));
1161 let zsh_hooks = include_str!(concat!(
1162 env!("CARGO_MANIFEST_DIR"),
1163 "/assets/shell/taskers-hooks.zsh"
1164 ));
1165 let fish_hooks = include_str!(concat!(
1166 env!("CARGO_MANIFEST_DIR"),
1167 "/assets/shell/taskers-hooks.fish"
1168 ));
1169
1170 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1171 assert!(
1172 asset.contains("TASKERS_LAST_META_AGENT"),
1173 "expected hook asset to invalidate cached agent metadata after exit"
1174 );
1175 assert!(
1176 asset.contains("TASKERS_LAST_META_AGENT_ACTIVE"),
1177 "expected hook asset to invalidate cached agent-active metadata after exit"
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn agent_proxy_owns_explicit_surface_agent_lifecycle_commands() {
1184 let bash_hooks = include_str!(concat!(
1185 env!("CARGO_MANIFEST_DIR"),
1186 "/assets/shell/taskers-hooks.bash"
1187 ));
1188 let zsh_hooks = include_str!(concat!(
1189 env!("CARGO_MANIFEST_DIR"),
1190 "/assets/shell/taskers-hooks.zsh"
1191 ));
1192 let fish_hooks = include_str!(concat!(
1193 env!("CARGO_MANIFEST_DIR"),
1194 "/assets/shell/taskers-hooks.fish"
1195 ));
1196 let agent_proxy = include_str!(concat!(
1197 env!("CARGO_MANIFEST_DIR"),
1198 "/assets/shell/taskers-agent-proxy.sh"
1199 ));
1200
1201 for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1202 assert!(
1203 !asset.contains("surface agent-start"),
1204 "expected hook asset to leave explicit lifecycle start to the proxy"
1205 );
1206 assert!(
1207 !asset.contains("surface agent-stop"),
1208 "expected hook asset to leave explicit lifecycle stop to the proxy"
1209 );
1210 }
1211 assert!(
1212 agent_proxy.contains("surface agent-start"),
1213 "expected proxy asset to emit explicit surface agent start commands"
1214 );
1215 assert!(
1216 agent_proxy.contains("surface agent-stop"),
1217 "expected proxy asset to emit explicit surface agent stop commands"
1218 );
1219 }
1220
1221 #[test]
1222 fn embedded_zsh_codex_command_emits_surface_lifecycle_via_proxy() {
1223 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1224 let runtime_root = unique_temp_dir("taskers-runtime-proxy-clean");
1225 install_runtime_assets(&runtime_root).expect("install runtime assets");
1226 super::install_agent_shims(&runtime_root).expect("install agent shims");
1227
1228 let home_dir = runtime_root.join("home");
1229 let real_bin_dir = runtime_root.join("real-bin");
1230 fs::create_dir_all(&home_dir).expect("home dir");
1231 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1232
1233 let taskersctl_path = runtime_root.join("taskersctl");
1234 let args_log = runtime_root.join("codex-args.log");
1235 write_executable(
1236 &taskersctl_path,
1237 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1238 );
1239 write_executable(
1240 &real_bin_dir.join("codex"),
1241 "#!/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",
1242 );
1243
1244 let original_home = std::env::var_os("HOME");
1245 let original_path = std::env::var_os("PATH");
1246 let original_zdotdir = std::env::var_os("ZDOTDIR");
1247 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1248 let test_log = runtime_root.join("taskersctl.log");
1249 unsafe {
1250 std::env::set_var("HOME", &home_dir);
1251 std::env::remove_var("ZDOTDIR");
1252 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1253 std::env::remove_var("TASKERS_SHELL_PROFILE");
1254 std::env::set_var(
1255 "PATH",
1256 format!(
1257 "{}:{}",
1258 real_bin_dir.display(),
1259 original_path
1260 .as_deref()
1261 .map(|value| value.to_string_lossy().into_owned())
1262 .unwrap_or_default()
1263 ),
1264 );
1265 }
1266
1267 let integration = ShellIntegration {
1268 root: runtime_root.clone(),
1269 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1270 real_shell: zsh_path,
1271 };
1272 let mut launch = integration.launch_spec();
1273 launch.args.push("-c".into());
1274 launch.args.push("codex".into());
1275 launch.env.insert(
1276 "TASKERS_CTL_PATH".into(),
1277 taskersctl_path.display().to_string(),
1278 );
1279 launch
1280 .env
1281 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1282 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1283 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1284 launch
1285 .env
1286 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1287 launch
1288 .env
1289 .insert("FAKE_CODEX_ARGS_LOG".into(), args_log.display().to_string());
1290
1291 let mut spec = CommandSpec::new(launch.program.display().to_string());
1292 spec.args = launch.args;
1293 spec.env = launch.env;
1294
1295 let spawned = PtySession::spawn(&spec).expect("spawn shell");
1296 let mut reader = spawned.reader;
1297 let mut buffer = [0u8; 1024];
1298 let mut output = String::new();
1299 loop {
1300 let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1301 if bytes_read == 0 {
1302 break;
1303 }
1304 output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1305 }
1306
1307 let log = fs::read_to_string(&test_log)
1308 .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1309 let codex_args = fs::read_to_string(&args_log)
1310 .unwrap_or_else(|error| panic!("read codex args log failed: {error}; output={output}"));
1311 assert!(
1312 codex_args.contains("-c\n") || codex_args.contains("-c "),
1313 "expected codex wrapper to inject config override, got: {codex_args}"
1314 );
1315 assert!(
1316 codex_args.contains("notify=[\"bash\",\""),
1317 "expected codex wrapper to inject notify helper override, got: {codex_args}"
1318 );
1319 assert!(
1320 log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1321 "expected start lifecycle in log, got: {log}"
1322 );
1323 assert!(
1324 log.contains("agent-hook stop --workspace ws --pane pn --surface sf --agent codex --title Codex --message Turn complete"),
1325 "expected codex notify helper to emit stop hook, got: {log}"
1326 );
1327 assert!(
1328 log.contains(
1329 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1330 ),
1331 "expected stop lifecycle in log, got: {log}"
1332 );
1333
1334 restore_env_var("HOME", original_home);
1335 restore_env_var("PATH", original_path);
1336 restore_env_var("ZDOTDIR", original_zdotdir);
1337 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1338 }
1339
1340 #[test]
1341 fn embedded_zsh_claude_command_injects_taskers_hooks_and_process_lifecycle() {
1342 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1343 let runtime_root = unique_temp_dir("taskers-runtime-proxy-claude");
1344 install_runtime_assets(&runtime_root).expect("install runtime assets");
1345 super::install_agent_shims(&runtime_root).expect("install agent shims");
1346
1347 let home_dir = runtime_root.join("home");
1348 let real_bin_dir = runtime_root.join("real-bin");
1349 fs::create_dir_all(&home_dir).expect("home dir");
1350 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1351
1352 let taskersctl_path = runtime_root.join("taskersctl");
1353 let args_log = runtime_root.join("claude-args.log");
1354 write_executable(
1355 &taskersctl_path,
1356 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1357 );
1358 write_executable(
1359 &real_bin_dir.join("claude"),
1360 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CLAUDE_ARGS_LOG\"\nexit 0\n",
1361 );
1362
1363 let original_home = std::env::var_os("HOME");
1364 let original_path = std::env::var_os("PATH");
1365 let original_zdotdir = std::env::var_os("ZDOTDIR");
1366 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1367 let test_log = runtime_root.join("taskersctl.log");
1368 unsafe {
1369 std::env::set_var("HOME", &home_dir);
1370 std::env::remove_var("ZDOTDIR");
1371 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1372 std::env::remove_var("TASKERS_SHELL_PROFILE");
1373 std::env::set_var(
1374 "PATH",
1375 format!(
1376 "{}:{}",
1377 real_bin_dir.display(),
1378 original_path
1379 .as_deref()
1380 .map(|value| value.to_string_lossy().into_owned())
1381 .unwrap_or_default()
1382 ),
1383 );
1384 }
1385
1386 let integration = ShellIntegration {
1387 root: runtime_root.clone(),
1388 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1389 real_shell: zsh_path,
1390 };
1391 let mut launch = integration.launch_spec();
1392 launch.args.push("-c".into());
1393 launch.args.push("claude --help".into());
1394 launch.env.insert(
1395 "TASKERS_CTL_PATH".into(),
1396 taskersctl_path.display().to_string(),
1397 );
1398 launch
1399 .env
1400 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1401 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1402 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1403 launch
1404 .env
1405 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1406 launch.env.insert(
1407 "FAKE_CLAUDE_ARGS_LOG".into(),
1408 args_log.display().to_string(),
1409 );
1410
1411 let mut spec = CommandSpec::new(launch.program.display().to_string());
1412 spec.args = launch.args;
1413 spec.env = launch.env;
1414
1415 let spawned = PtySession::spawn(&spec).expect("spawn shell");
1416 let mut reader = spawned.reader;
1417 let mut buffer = [0u8; 1024];
1418 let mut output = String::new();
1419 loop {
1420 let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1421 if bytes_read == 0 {
1422 break;
1423 }
1424 output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1425 }
1426
1427 let log = fs::read_to_string(&test_log)
1428 .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1429 let claude_args = fs::read_to_string(&args_log).unwrap_or_else(|error| {
1430 panic!("read claude args log failed: {error}; output={output}")
1431 });
1432 let hook_path = runtime_root.join("taskers-claude-hook.sh");
1433 assert!(
1434 claude_args.contains("--settings"),
1435 "expected claude wrapper to inject hook settings, got: {claude_args}"
1436 );
1437 assert!(
1438 claude_args.contains(&hook_path.display().to_string())
1439 && claude_args.contains("user-prompt-submit"),
1440 "expected claude wrapper to inject prompt-submit hook path, got: {claude_args}"
1441 );
1442 assert!(
1443 claude_args.contains(&hook_path.display().to_string()) && claude_args.contains("stop"),
1444 "expected claude wrapper to inject stop hook path, got: {claude_args}"
1445 );
1446 assert!(
1447 log.contains(
1448 "surface agent-start --workspace ws --pane pn --surface sf --agent claude"
1449 ),
1450 "expected start lifecycle in log, got: {log}"
1451 );
1452 assert!(
1453 log.contains(
1454 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1455 ),
1456 "expected stop lifecycle in log, got: {log}"
1457 );
1458
1459 restore_env_var("HOME", original_home);
1460 restore_env_var("PATH", original_path);
1461 restore_env_var("ZDOTDIR", original_zdotdir);
1462 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1463 }
1464
1465 #[test]
1466 fn claude_code_shim_preserves_binary_lookup_and_quotes_hook_paths() {
1467 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1468 let runtime_root = unique_temp_dir("taskers runtime claude code");
1469 install_runtime_assets(&runtime_root).expect("install runtime assets");
1470 super::install_agent_shims(&runtime_root).expect("install agent shims");
1471
1472 let real_bin_dir = runtime_root.join("real-bin");
1473 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1474
1475 let capture_path = runtime_root.join("claude-code-capture.log");
1476 write_executable(
1477 &real_bin_dir.join("claude-code"),
1478 "#!/bin/sh\nprintf 'target=%s\\n' \"${TASKERS_AGENT_PROXY_TARGET:-}\" >> \"$FAKE_CLAUDE_CAPTURE\"\nprintf '%s\\n' \"$@\" >> \"$FAKE_CLAUDE_CAPTURE\"\nexit 0\n",
1479 );
1480
1481 let original_path = std::env::var_os("PATH");
1482 let shim_path = runtime_root.join("bin").join("claude-code");
1483 let output = Command::new(&shim_path)
1484 .env(
1485 "PATH",
1486 format!(
1487 "{}:{}",
1488 real_bin_dir.display(),
1489 original_path
1490 .as_deref()
1491 .map(|value| value.to_string_lossy().into_owned())
1492 .unwrap_or_default()
1493 ),
1494 )
1495 .env("FAKE_CLAUDE_CAPTURE", &capture_path)
1496 .arg("--help")
1497 .output()
1498 .expect("run claude-code shim");
1499
1500 assert!(
1501 output.status.success(),
1502 "expected claude-code shim to succeed, stdout={}, stderr={}",
1503 String::from_utf8_lossy(&output.stdout),
1504 String::from_utf8_lossy(&output.stderr)
1505 );
1506
1507 let capture = fs::read_to_string(&capture_path).expect("read capture log");
1508 let hook_path = runtime_root.join("taskers-claude-hook.sh");
1509 assert!(
1510 capture.contains("target=claude-code"),
1511 "expected shim to preserve the invoked claude-code lookup target, got: {capture}"
1512 );
1513 assert!(
1514 capture.contains("--settings"),
1515 "expected claude-code shim to forward hook settings, got: {capture}"
1516 );
1517 assert!(
1518 capture.contains(&format!("'{}' user-prompt-submit", hook_path.display())),
1519 "expected claude-code hook path to be single-quoted inside settings, got: {capture}"
1520 );
1521
1522 restore_env_var("PATH", original_path);
1523 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1524 }
1525
1526 #[test]
1527 fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() {
1528 let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1529 let runtime_root = unique_temp_dir("taskers-runtime-proxy-interrupt");
1530 install_runtime_assets(&runtime_root).expect("install runtime assets");
1531 super::install_agent_shims(&runtime_root).expect("install agent shims");
1532
1533 let home_dir = runtime_root.join("home");
1534 let real_bin_dir = runtime_root.join("real-bin");
1535 fs::create_dir_all(&home_dir).expect("home dir");
1536 fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1537
1538 let taskersctl_path = runtime_root.join("taskersctl");
1539 write_executable(
1540 &taskersctl_path,
1541 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1542 );
1543 write_executable(
1544 &real_bin_dir.join("codex"),
1545 "#!/bin/sh\ntrap 'exit 130' INT\nwhile :; do sleep 1; done\n",
1546 );
1547
1548 let original_home = std::env::var_os("HOME");
1549 let original_path = std::env::var_os("PATH");
1550 let original_zdotdir = std::env::var_os("ZDOTDIR");
1551 let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1552 let test_log = runtime_root.join("taskersctl.log");
1553 unsafe {
1554 std::env::set_var("HOME", &home_dir);
1555 std::env::remove_var("ZDOTDIR");
1556 std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1557 std::env::remove_var("TASKERS_SHELL_PROFILE");
1558 std::env::set_var(
1559 "PATH",
1560 format!(
1561 "{}:{}",
1562 real_bin_dir.display(),
1563 original_path
1564 .as_deref()
1565 .map(|value| value.to_string_lossy().into_owned())
1566 .unwrap_or_default()
1567 ),
1568 );
1569 }
1570
1571 let integration = ShellIntegration {
1572 root: runtime_root.clone(),
1573 wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1574 real_shell: zsh_path,
1575 };
1576 let mut launch = integration.launch_spec();
1577 launch.args.push("-c".into());
1578 launch.args.push("codex".into());
1579 launch.env.insert(
1580 "TASKERS_CTL_PATH".into(),
1581 taskersctl_path.display().to_string(),
1582 );
1583 launch
1584 .env
1585 .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1586 launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1587 launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1588 launch
1589 .env
1590 .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1591
1592 let mut spec = CommandSpec::new(launch.program.display().to_string());
1593 spec.args = launch.args;
1594 spec.env = launch.env;
1595
1596 let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1597 std::thread::sleep(Duration::from_millis(250));
1598 spawned
1599 .session
1600 .write_all(b"\x03")
1601 .expect("send ctrl-c to shell");
1602
1603 let mut reader = spawned.reader;
1604 let mut buffer = [0u8; 1024];
1605 while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1606
1607 let log = fs::read_to_string(&test_log).expect("read lifecycle log");
1608 assert!(
1609 log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1610 "expected start lifecycle in log, got: {log}"
1611 );
1612 assert!(
1613 log.contains(
1614 "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 130"
1615 ),
1616 "expected interrupted stop lifecycle in log, got: {log}"
1617 );
1618
1619 restore_env_var("HOME", original_home);
1620 restore_env_var("PATH", original_path);
1621 restore_env_var("ZDOTDIR", original_zdotdir);
1622 fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1623 }
1624
1625 fn unique_temp_dir(prefix: &str) -> PathBuf {
1626 let unique = SystemTime::now()
1627 .duration_since(SystemTime::UNIX_EPOCH)
1628 .expect("time")
1629 .as_nanos();
1630 std::env::temp_dir().join(format!("{prefix}-{unique}"))
1631 }
1632
1633 fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1634 unsafe {
1635 match value {
1636 Some(value) => std::env::set_var(key, value),
1637 None => std::env::remove_var(key),
1638 }
1639 }
1640 }
1641
1642 fn write_executable(path: &PathBuf, content: &str) {
1643 fs::write(path, content).expect("write script");
1644 #[cfg(unix)]
1645 {
1646 let mut permissions = fs::metadata(path).expect("metadata").permissions();
1647 permissions.set_mode(0o755);
1648 fs::set_permissions(path, permissions).expect("chmod script");
1649 }
1650 }
1651}