1use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, resolve_program, ServiceState, ServiceUnit};
25
26pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29pub const TEAMS_PACKAGE: &str = "@volter/supercode-teams";
31
32pub const SERVICE_DIR: &str = "service";
34
35pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-machine";
37
38pub fn connector_service_name(server_id: &str, team_id: &str, context: &str) -> String {
40 let mut hash = 0xcbf29ce484222325_u64;
43 for byte in [server_id, team_id, context].join("\0").bytes() {
44 hash ^= u64::from(byte);
45 hash = hash.wrapping_mul(0x100000001b3);
46 }
47 format!("dev.volter.supercode-teams-connector-{hash:016x}")
48}
49
50fn plist_text(value: &str) -> String {
51 value
52 .replace('&', "&")
53 .replace('<', "<")
54 .replace('>', ">")
55}
56
57fn service_text(value: &str) -> Result<&str, TeamsError> {
58 if value.chars().any(char::is_control) {
59 return Err(TeamsError::Service {
60 action: "render",
61 detail: "service parameters cannot contain control characters".into(),
62 });
63 }
64 Ok(value)
65}
66
67fn systemd_arg(value: &str) -> String {
68 format!(
69 "\"{}\"",
70 value
71 .replace('\\', "\\\\")
72 .replace('"', "\\\"")
73 .replace('%', "%%")
74 .replace('$', "$$")
75 )
76}
77
78pub fn connector_service_unit(
80 teams_home: &Path,
81 supercode_home: &Path,
82 entry: &Path,
83 node: &str,
84 supercode: &Path,
85 context: &str,
86 cwd: &Path,
87 server_id: &str,
88 team_id: &str,
89) -> Result<ServiceUnit, TeamsError> {
90 let teams_home_text = teams_home.display().to_string();
91 let supercode_home_text = supercode_home.display().to_string();
92 let entry_text = entry.display().to_string();
93 let supercode_text = supercode.display().to_string();
94 let workspace_text = cwd.display().to_string();
95 for value in [
96 teams_home_text.as_str(),
97 supercode_home_text.as_str(),
98 entry_text.as_str(),
99 node,
100 supercode_text.as_str(),
101 context,
102 workspace_text.as_str(),
103 server_id,
104 team_id,
105 ] {
106 service_text(value)?;
107 }
108 let label = connector_service_name(server_id, team_id, context);
109 let path = teams_home
110 .join(SERVICE_DIR)
111 .join(format!("{label}.{}", connector_unit_suffix()));
112 let node = absolute_program(node);
113 let entry = entry_text;
114 let workspace = workspace_text;
115 let home = supercode_home_text;
116 let supercode = supercode_text;
117 if cfg!(windows) {
118 let log_path = teams_home.join(SERVICE_DIR).join(format!("{label}.log"));
119 return windows_task(
120 &path,
121 &label,
122 &format!("supercode Teams connector ({context})"),
123 &service_env_path(teams_home, &label),
124 &[
125 ("SUPERCODE_HOME", home.as_str()),
126 ("SUPERCODE_BIN", supercode.as_str()),
127 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
128 ],
129 &node,
130 &[
131 entry.as_str(),
132 "teams",
133 "connect",
134 "--foreground",
135 "--context",
136 context,
137 "--cwd",
138 workspace.as_str(),
139 ],
140 &workspace,
141 );
142 }
143 if cfg!(target_os = "macos") {
144 let node = plist_text(&node);
145 let entry = plist_text(&entry);
146 let workspace = plist_text(&workspace);
147 let home = plist_text(&home);
148 let supercode = plist_text(&supercode);
149 let context = plist_text(context);
150 let search_path = plist_text(&service_path());
151 let text = format!(
152 r#"<?xml version="1.0" encoding="UTF-8"?>
153<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154<plist version="1.0"><dict>
155 <key>Label</key><string>{label}</string>
156 <key>ProgramArguments</key><array><string>{node}</string><string>{entry}</string><string>teams</string><string>connect</string><string>--context</string><string>{context}</string><string>--cwd</string><string>{workspace}</string></array>
157 <key>EnvironmentVariables</key><dict><key>SUPERCODE_HOME</key><string>{home}</string><key>SUPERCODE_BIN</key><string>{supercode}</string><key>PATH</key><string>{search_path}</string></dict>
158 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
159 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
160 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
161</dict></plist>
162"#,
163 plist_text(&teams_home_text),
164 plist_text(&teams_home_text)
165 );
166 Ok(ServiceUnit {
167 kind: "launchd",
168 path: path.clone(),
169 text,
170 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
171 files: Vec::new(),
172 })
173 } else {
174 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
175 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
176 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
177 let node = systemd_arg(&node);
178 let entry = systemd_arg(&entry);
179 let workspace = systemd_arg(&workspace);
180 let context_description = context.replace('%', "%%").replace('$', "$$");
181 let context = systemd_arg(context);
182 let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\n\n[Install]\nWantedBy=default.target\n");
183 Ok(ServiceUnit {
184 kind: "systemd",
185 path: path.clone(),
186 text,
187 install_command: format!(
188 "systemctl --user link {} && systemctl --user enable --now {label}",
189 path.display()
190 ),
191 files: Vec::new(),
192 })
193 }
194}
195
196fn connector_unit_suffix() -> &'static str {
198 if cfg!(windows) {
199 "xml"
200 } else if cfg!(target_os = "macos") {
201 "plist"
202 } else {
203 "service"
204 }
205}
206
207fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
209 teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
210}
211
212#[allow(clippy::too_many_arguments)]
215fn windows_task(
216 path: &Path,
217 label: &str,
218 description: &str,
219 env_path: &Path,
220 env: &[(&str, &str)],
221 node: &str,
222 node_args: &[&str],
223 working_directory: &str,
224) -> Result<ServiceUnit, TeamsError> {
225 let env_text = env
231 .iter()
232 .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
233 .collect::<Result<String, _>>()?;
234 let env_flag = format!("--env-file={}", env_path.display());
235 let mut arguments = vec![node, env_flag.as_str()];
236 arguments.extend_from_slice(node_args);
237 for value in arguments.iter().chain([&working_directory]) {
239 if value.contains('%') {
240 return Err(TeamsError::Service {
241 action: "render",
242 detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
243 });
244 }
245 }
246 let arguments = arguments
247 .iter()
248 .map(|argument| windows_arg(argument))
249 .collect::<Vec<_>>()
250 .join(" ");
251 let user = windows_user();
252 let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
253 .join(r"System32\conhost.exe")
254 .display()
255 .to_string();
256 let text = format!(
257 r#"<?xml version="1.0" encoding="UTF-16"?>
258<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
259 <RegistrationInfo><Description>{}</Description></RegistrationInfo>
260 <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{}</UserId></LogonTrigger><TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></TimeTrigger></Triggers>
261 <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
262 <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
263 <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
264</Task>
265"#,
266 xml_text(description),
267 xml_text(&user),
268 xml_text(&user),
269 xml_text(&conhost),
270 xml_text(&arguments),
271 xml_text(working_directory),
272 );
273 Ok(ServiceUnit {
274 kind: "schtasks",
275 path: path.to_path_buf(),
276 text,
277 install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
278 files: vec![(env_path.to_path_buf(), env_text)],
279 })
280}
281
282fn xml_text(value: &str) -> String {
284 plist_text(value).replace('"', """)
285}
286
287fn windows_arg(value: &str) -> String {
290 if !value.is_empty() && !value.contains([' ', '\t', '"']) {
291 return value.to_string();
292 }
293 let mut quoted = String::from("\"");
294 let mut backslashes = 0;
295 for character in value.chars() {
296 match character {
297 '\\' => backslashes += 1,
298 '"' => {
299 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
300 quoted.push('"');
301 backslashes = 0;
302 }
303 other => {
304 quoted.push_str(&"\\".repeat(backslashes));
305 quoted.push(other);
306 backslashes = 0;
307 }
308 }
309 }
310 quoted.push_str(&"\\".repeat(backslashes * 2));
311 quoted.push('"');
312 quoted
313}
314
315fn env_file_value(value: &str) -> Result<String, TeamsError> {
318 ['\'', '`']
319 .into_iter()
320 .find(|quote| !value.contains(*quote))
321 .map(|quote| format!("{quote}{value}{quote}"))
322 .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
323 .ok_or_else(|| TeamsError::Service {
324 action: "render",
325 detail: format!("cannot write `{value}` into a service's environment file"),
326 })
327}
328
329fn windows_user() -> String {
331 let user = std::env::var("USERNAME").unwrap_or_default();
332 match std::env::var("USERDOMAIN") {
333 Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
334 _ => user,
335 }
336}
337
338#[derive(Debug, thiserror::Error)]
340pub enum TeamsError {
341 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
343 NoEntry {
344 searched: String,
346 },
347 #[error("teams service: {action} failed: {detail}")]
349 Service {
350 action: &'static str,
352 detail: String,
354 },
355 #[error("teams file `{}`: {source}", path.display())]
357 File {
358 path: PathBuf,
360 source: std::io::Error,
362 },
363}
364
365fn service_path() -> String {
369 std::env::var("PATH")
370 .ok()
371 .filter(|path| !path.trim().is_empty())
372 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
373}
374
375pub fn teams_home() -> PathBuf {
380 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
381 if !home.is_empty() {
382 return PathBuf::from(home);
383 }
384 }
385 crate::agent::global_instructions_dir().join("teams")
386}
387
388pub fn teams_entry() -> Result<PathBuf, TeamsError> {
398 let mut searched = Vec::new();
399 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
400 let path = PathBuf::from(explicit);
401 if path.is_file() {
402 return Ok(path);
403 }
404 searched.push(path.display().to_string());
405 }
406 let mut roots: Vec<PathBuf> = Vec::new();
407 if let Ok(exe) = std::env::current_exe() {
408 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
410 }
411 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
416 roots.push(workspace.to_path_buf());
417 }
418 for root in roots {
419 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
420 if candidate.is_file() {
421 return Ok(candidate);
422 }
423 searched.push(candidate.display().to_string());
424 }
425 if let Ok(exe) = std::env::current_exe() {
430 for modules in exe
431 .ancestors()
432 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
433 {
434 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
435 if candidate.is_file() {
436 return Ok(candidate);
437 }
438 searched.push(candidate.display().to_string());
439 }
440 }
441 if let Some(global) = global_npm_root() {
442 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
443 if candidate.is_file() {
444 return Ok(candidate);
445 }
446 searched.push(candidate.display().to_string());
447 }
448 Err(TeamsError::NoEntry {
449 searched: searched.join(", "),
450 })
451}
452
453fn global_npm_root() -> Option<PathBuf> {
455 let output = std::process::Command::new(resolve_program("npm"))
456 .args(["root", "-g"])
457 .stdin(std::process::Stdio::null())
458 .stderr(std::process::Stdio::null())
459 .output()
460 .ok()?;
461 if !output.status.success() {
462 return None;
463 }
464 let text = String::from_utf8_lossy(&output.stdout);
465 let root = text.trim();
466 if root.is_empty() {
467 return None;
468 }
469 Some(PathBuf::from(root))
470}
471
472pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
481 let home_display = home.display().to_string();
482 let entry_display = entry.display().to_string();
483 for value in [home_display.as_str(), entry_display.as_str(), node] {
485 service_text(value)?;
486 }
487 if cfg!(windows) {
488 let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
489 return windows_task(
490 &home.join(SERVICE_DIR).join(unit_file_name()),
491 SERVICE_NAME,
492 &format!("supercode teams machine daemon ({home_display})"),
493 &service_env_path(home, SERVICE_NAME),
494 &[
495 ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
496 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
497 ],
498 node,
499 &[entry_display.as_str(), "machine", "start"],
500 &home_display,
501 );
502 }
503 if cfg!(target_os = "macos") {
504 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
505 let text = format!(
506 r#"<?xml version="1.0" encoding="UTF-8"?>
507<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
508<plist version="1.0">
509<dict>
510 <key>Label</key><string>{SERVICE_NAME}</string>
511 <key>ProgramArguments</key>
512 <array>
513 <string>{node}</string>
514 <string>{entry_display}</string>
515 <string>machine</string>
516 <string>start</string>
517 </array>
518 <key>EnvironmentVariables</key>
519 <dict>
520 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
521 </dict>
522 <key>RunAtLoad</key><true/>
523 <key>KeepAlive</key><true/>
524 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
525 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
526</dict>
527</plist>
528"#
529 );
530 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
531 Ok(ServiceUnit {
532 kind: "launchd",
533 path,
534 text,
535 install_command: install,
536 files: Vec::new(),
537 })
538 } else {
539 let path = home
540 .join(SERVICE_DIR)
541 .join(format!("{SERVICE_NAME}.service"));
542 let text = format!(
543 "[Unit]\n\
544 Description=supercode teams machine daemon ({home_display})\n\
545 After=network.target\n\
546 \n\
547 [Service]\n\
548 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
549 ExecStart={node} {entry_display} machine start\n\
550 Restart=on-failure\n\
551 KillSignal=SIGTERM\n\
552 \n\
553 [Install]\n\
554 WantedBy=default.target\n"
555 );
556 let install = format!(
557 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
558 path.display()
559 );
560 Ok(ServiceUnit {
561 kind: "systemd",
562 path,
563 text,
564 install_command: install,
565 files: Vec::new(),
566 })
567 }
568}
569
570pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
572 if let Some(parent) = unit.path.parent() {
573 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
574 path: unit.path.clone(),
575 source,
576 })?;
577 }
578 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
579 path: unit.path.clone(),
580 source,
581 })?;
582 for (path, text) in &unit.files {
583 std::fs::write(path, text).map_err(|source| TeamsError::File {
584 path: path.clone(),
585 source,
586 })?;
587 }
588 Ok(())
589}
590
591fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
593 let output = std::process::Command::new(program).args(args).output()?;
594 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
595 text.push_str(&String::from_utf8_lossy(&output.stderr));
596 Ok((output.status.success(), text.trim().to_string()))
597}
598
599#[cfg(target_os = "macos")]
600fn gui_domain() -> String {
601 format!("gui/{}", unsafe { libc::getuid() })
603}
604
605pub fn service_status() -> ServiceState {
609 platform_status()
610}
611
612#[cfg(target_os = "macos")]
613fn platform_status() -> ServiceState {
614 let label = SERVICE_NAME.to_string();
615 let target = format!("{}/{SERVICE_NAME}", gui_domain());
616 match run_tool("launchctl", &["print", &target]) {
617 Ok((true, text)) => ServiceState {
618 kind: "launchd",
619 label,
620 installed: true,
621 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
622 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
623 },
624 Ok((false, _)) => ServiceState {
625 kind: "launchd",
626 label,
627 installed: false,
628 pid: None,
629 detail: format!("not bootstrapped in {}", gui_domain()),
630 },
631 Err(error) => ServiceState {
632 kind: "launchd",
633 label,
634 installed: false,
635 pid: None,
636 detail: format!("launchctl unavailable: {error}"),
637 },
638 }
639}
640
641#[cfg(all(unix, not(target_os = "macos")))]
642fn platform_status() -> ServiceState {
643 let label = SERVICE_NAME.to_string();
644 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
645 Ok((active, text)) => {
646 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
647 .map(|(ok, _)| ok)
648 .unwrap_or(false);
649 ServiceState {
650 kind: "systemd",
651 label,
652 installed: active || known,
653 pid: None,
654 detail: if text.is_empty() {
655 "unknown".into()
656 } else {
657 text
658 },
659 }
660 }
661 Err(error) => ServiceState {
662 kind: "systemd",
663 label,
664 installed: false,
665 pid: None,
666 detail: format!("systemctl unavailable: {error}"),
667 },
668 }
669}
670
671#[cfg(not(unix))]
672fn platform_status() -> ServiceState {
673 named_service_status(SERVICE_NAME)
674}
675
676#[cfg(target_os = "macos")]
678fn field_of(text: &str, key: &str) -> Option<String> {
679 text.lines()
680 .find_map(|line| line.trim().strip_prefix(key))
681 .map(|value| value.trim().to_string())
682}
683
684fn unit_file_name() -> String {
686 if cfg!(windows) {
687 format!("{SERVICE_NAME}.xml")
688 } else if cfg!(target_os = "macos") {
689 format!("{SERVICE_NAME}.plist")
690 } else {
691 format!("{SERVICE_NAME}.service")
692 }
693}
694
695pub fn install_service(
701 home: &Path,
702 entry: &Path,
703 node: &str,
704) -> Result<(ServiceUnit, ServiceState), TeamsError> {
705 let existing = service_status();
706 if existing.installed {
707 return Err(TeamsError::Service {
708 action: "install",
709 detail: format!(
710 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
711 existing.label, existing.detail
712 ),
713 });
714 }
715 let unit = service_unit(home, entry, &absolute_program(node))?;
716 write_unit(&unit)?;
717 platform_install(&unit)?;
718 Ok((unit, service_status()))
719}
720
721pub fn install_connector_service(
727 unit: &ServiceUnit,
728 label: &str,
729) -> Result<ServiceState, TeamsError> {
730 let existing = named_service_status(label);
731 if unit.path.exists() {
732 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
733 path: unit.path.clone(),
734 source,
735 })?;
736 let same = old == unit.text
738 && unit
739 .files
740 .iter()
741 .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
742 if same && existing.installed {
743 return Ok(existing);
744 }
745 if !same && existing.installed {
746 named_platform_uninstall(label)?;
747 }
748 } else if existing.installed {
749 return Err(TeamsError::Service {
750 action: "install",
751 detail: format!(
752 "service manager already owns `{label}` without its expected unit file"
753 ),
754 });
755 }
756 write_unit(unit)?;
757 named_platform_install(unit, label)?;
758 Ok(named_service_status(label))
759}
760
761pub fn register_message_tools(supercode: &Path) -> Vec<String> {
767 let program = supercode.display().to_string();
768 let mut report = Vec::new();
769 for (harness, get, add) in [
770 (
771 "claude",
772 vec!["mcp", "get", "supercode"],
773 vec![
774 "mcp",
775 "add",
776 "--scope",
777 "user",
778 "supercode",
779 "--",
780 &program,
781 "message",
782 "mcp",
783 ],
784 ),
785 (
786 "codex",
787 vec!["mcp", "get", "supercode"],
788 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
789 ),
790 ] {
791 let run = |args: &[&str]| {
792 std::process::Command::new(resolve_program(harness))
793 .args(args)
794 .stdin(std::process::Stdio::null())
795 .output()
796 };
797 match run(&get) {
798 Err(_) => report.push(format!("{harness}: not installed")),
799 Ok(found) if found.status.success() => {
800 report.push(format!("{harness}: already has supercode's tools"))
801 }
802 Ok(_) => match run(&add) {
803 Ok(added) if added.status.success() => report.push(format!(
804 "{harness}: supercode's tools added (new sessions load them)"
805 )),
806 Ok(added) => report.push(format!(
807 "{harness}: could not add supercode's tools: {}",
808 String::from_utf8_lossy(&added.stderr).trim()
809 )),
810 Err(error) => report.push(format!(
811 "{harness}: could not add supercode's tools: {error}"
812 )),
813 },
814 }
815 }
816 report
817}
818
819pub fn uninstall_connector_service(
821 teams_home: &Path,
822 label: &str,
823) -> Result<ServiceState, TeamsError> {
824 named_platform_uninstall(label)?;
825 let unit = teams_home
826 .join(SERVICE_DIR)
827 .join(format!("{label}.{}", connector_unit_suffix()));
828 for path in [unit, service_env_path(teams_home, label)] {
829 match std::fs::remove_file(&path) {
830 Ok(()) => {}
831 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
832 Err(source) => return Err(TeamsError::File { path, source }),
833 }
834 }
835 Ok(named_service_status(label))
836}
837
838pub fn connector_service_status(label: &str) -> ServiceState {
840 named_service_status(label)
841}
842
843#[cfg(target_os = "macos")]
844fn named_service_status(label: &str) -> ServiceState {
845 let target = format!("{}/{label}", gui_domain());
846 match run_tool("launchctl", &["print", &target]) {
847 Ok((true, text)) => ServiceState {
848 kind: "launchd",
849 label: label.into(),
850 installed: true,
851 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
852 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
853 },
854 Ok((false, _)) => ServiceState {
855 kind: "launchd",
856 label: label.into(),
857 installed: false,
858 pid: None,
859 detail: format!("not bootstrapped in {}", gui_domain()),
860 },
861 Err(error) => ServiceState {
862 kind: "launchd",
863 label: label.into(),
864 installed: false,
865 pid: None,
866 detail: format!("launchctl unavailable: {error}"),
867 },
868 }
869}
870
871#[cfg(all(unix, not(target_os = "macos")))]
872fn named_service_status(label: &str) -> ServiceState {
873 match run_tool("systemctl", &["--user", "is-active", label]) {
874 Ok((active, text)) => {
875 let known = run_tool("systemctl", &["--user", "is-enabled", label])
876 .map(|(ok, _)| ok)
877 .unwrap_or(false);
878 ServiceState {
879 kind: "systemd",
880 label: label.into(),
881 installed: active || known,
882 pid: None,
883 detail: if text.is_empty() {
884 "unknown".into()
885 } else {
886 text
887 },
888 }
889 }
890 Err(error) => ServiceState {
891 kind: "systemd",
892 label: label.into(),
893 installed: false,
894 pid: None,
895 detail: format!("systemctl unavailable: {error}"),
896 },
897 }
898}
899
900#[cfg(not(unix))]
901fn named_service_status(label: &str) -> ServiceState {
902 match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
903 Ok((true, text)) => ServiceState {
905 kind: "schtasks",
906 label: label.into(),
907 installed: true,
908 pid: None,
909 detail: text
910 .lines()
911 .next()
912 .and_then(|line| line.rsplit(',').next())
913 .map(|state| state.trim_matches('"').to_string())
914 .filter(|state| !state.is_empty())
915 .unwrap_or_else(|| "registered".into()),
916 },
917 Ok((false, _)) => ServiceState {
918 kind: "schtasks",
919 label: label.into(),
920 installed: false,
921 pid: None,
922 detail: "no scheduled task".into(),
923 },
924 Err(error) => ServiceState {
925 kind: "schtasks",
926 label: label.into(),
927 installed: false,
928 pid: None,
929 detail: format!("schtasks unavailable: {error}"),
930 },
931 }
932}
933
934#[cfg(target_os = "macos")]
935fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
936 platform_install(unit)
937}
938#[cfg(all(unix, not(target_os = "macos")))]
939fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
940 let path = unit.path.display().to_string();
941 for args in [
942 vec!["--user", "link", path.as_str()],
943 vec!["--user", "enable", "--now", label],
944 ] {
945 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
946 action: "install",
947 detail: format!("systemctl: {error}"),
948 })?;
949 if !ok {
950 return Err(TeamsError::Service {
951 action: "install",
952 detail: format!("systemctl {}: {text}", args.join(" ")),
953 });
954 }
955 }
956 Ok(())
957}
958#[cfg(not(unix))]
959fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
960 let task = unit.path.with_extension("utf16.xml");
962 let bytes: Vec<u8> = [0xFF, 0xFE]
963 .into_iter()
964 .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
965 .collect();
966 std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
967 path: task.clone(),
968 source,
969 })?;
970 let task_path = task.display().to_string();
971 let created = run_tool(
972 "schtasks",
973 &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
974 )
975 .map_err(|error| TeamsError::Service {
976 action: "install",
977 detail: format!("schtasks: {error}"),
978 })
979 .and_then(|(ok, text)| {
980 if ok {
981 Ok(())
982 } else {
983 Err(TeamsError::Service {
984 action: "install",
985 detail: format!("schtasks /Create: {text}"),
986 })
987 }
988 });
989 if let Err(error) = created {
990 let _ = std::fs::remove_file(&task);
991 return Err(error);
992 }
993 let _ = run_tool("schtasks", &["/End", "/TN", label]);
998 let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
999 let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1000 action: "install",
1001 detail: format!("schtasks: {error}"),
1002 })?;
1003 if ok {
1004 Ok(())
1005 } else {
1006 Err(TeamsError::Service {
1007 action: "install",
1008 detail: format!("schtasks {}: {text}", args[0]),
1009 })
1010 }
1011 });
1012 let _ = std::fs::remove_file(&task);
1013 result
1014}
1015
1016#[cfg(target_os = "macos")]
1017fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1018 let target = format!("{}/{label}", gui_domain());
1019 let (ok, text) =
1020 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1021 action: "uninstall",
1022 detail: format!("launchctl: {error}"),
1023 })?;
1024 if !ok && !text.contains("No such process") && !text.contains("not find") {
1025 return Err(TeamsError::Service {
1026 action: "uninstall",
1027 detail: format!("launchctl bootout {target}: {text}"),
1028 });
1029 }
1030 for _ in 0..50 {
1033 if !named_service_status(label).installed {
1034 break;
1035 }
1036 std::thread::sleep(std::time::Duration::from_millis(100));
1037 }
1038 Ok(())
1039}
1040#[cfg(all(unix, not(target_os = "macos")))]
1041fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1042 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1043 Ok(())
1044}
1045#[cfg(not(unix))]
1046fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1047 let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1049 TeamsError::Service {
1050 action: "uninstall",
1051 detail: format!("schtasks: {error}"),
1052 }
1053 })?;
1054 if !ok && named_service_status(label).installed {
1055 return Err(TeamsError::Service {
1056 action: "uninstall",
1057 detail: format!("schtasks /Delete: {text}"),
1058 });
1059 }
1060 let env_path = service_env_path(&teams_home(), label);
1064 let stopped = std::process::Command::new("powershell.exe")
1065 .args([
1066 "-NoProfile",
1067 "-NonInteractive",
1068 "-Command",
1069 "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='conhost.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:SUPERCODE_SERVICE_ENV_FILE) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
1070 ])
1071 .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1072 .stdin(std::process::Stdio::null())
1073 .output();
1074 match stopped {
1075 Ok(output) if output.status.success() => Ok(()),
1076 Ok(output) => Err(TeamsError::Service {
1077 action: "uninstall",
1078 detail: format!(
1079 "the task is gone, but what it started may still run: {}",
1080 String::from_utf8_lossy(&output.stderr).trim()
1081 ),
1082 }),
1083 Err(error) => Err(TeamsError::Service {
1084 action: "uninstall",
1085 detail: format!(
1086 "the task is gone, but what it started may still run: powershell: {error}"
1087 ),
1088 }),
1089 }
1090}
1091
1092#[cfg(target_os = "macos")]
1093fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1094 let path = unit.path.display().to_string();
1095 let (ok, text) =
1096 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1097 TeamsError::Service {
1098 action: "install",
1099 detail: format!("launchctl: {error}"),
1100 }
1101 })?;
1102 if !ok {
1103 return Err(TeamsError::Service {
1104 action: "install",
1105 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1106 });
1107 }
1108 Ok(())
1109}
1110
1111#[cfg(all(unix, not(target_os = "macos")))]
1114fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1115 let path = unit.path.display().to_string();
1116 for args in [
1117 vec!["--user", "link", path.as_str()],
1118 vec!["--user", "enable", "--now", SERVICE_NAME],
1119 ] {
1120 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1121 action: "install",
1122 detail: format!("systemctl: {error}"),
1123 })?;
1124 if !ok {
1125 return Err(TeamsError::Service {
1126 action: "install",
1127 detail: format!("systemctl {}: {text}", args.join(" ")),
1128 });
1129 }
1130 }
1131 Ok(())
1132}
1133
1134#[cfg(not(unix))]
1135fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1136 named_platform_install(unit, SERVICE_NAME)
1137}
1138
1139pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1144 platform_uninstall()?;
1145 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1146 for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1148 match std::fs::remove_file(&path) {
1149 Ok(()) => {}
1150 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1151 Err(source) => return Err(TeamsError::File { path, source }),
1152 }
1153 }
1154 let mut state = service_status();
1157 for _ in 0..40 {
1158 if !state.installed {
1159 break;
1160 }
1161 std::thread::sleep(std::time::Duration::from_millis(100));
1162 state = service_status();
1163 }
1164 Ok(state)
1165}
1166
1167#[cfg(target_os = "macos")]
1168fn platform_uninstall() -> Result<(), TeamsError> {
1169 let target = format!("{}/{SERVICE_NAME}", gui_domain());
1170 let (ok, text) =
1171 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1172 action: "uninstall",
1173 detail: format!("launchctl: {error}"),
1174 })?;
1175 if !ok && !text.contains("No such process") && !text.contains("not find") {
1177 return Err(TeamsError::Service {
1178 action: "uninstall",
1179 detail: format!("launchctl bootout {target}: {text}"),
1180 });
1181 }
1182 Ok(())
1183}
1184
1185#[cfg(all(unix, not(target_os = "macos")))]
1186fn platform_uninstall() -> Result<(), TeamsError> {
1187 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1188 Ok(())
1189}
1190
1191#[cfg(not(unix))]
1192fn platform_uninstall() -> Result<(), TeamsError> {
1193 named_platform_uninstall(SERVICE_NAME)
1194}
1195
1196#[cfg(test)]
1197mod connector_service_tests {
1198 use super::*;
1199
1200 #[test]
1201 fn connector_unit_is_context_scoped_and_contains_no_credential() {
1202 let unit = connector_service_unit(
1203 Path::new("/tmp/teams home"),
1204 Path::new("/tmp/supercode home"),
1205 Path::new("/tmp/sdk/teams/bin/teams.mjs"),
1206 "/usr/bin/node",
1207 Path::new("/tmp/bin/supercode"),
1208 "work",
1209 Path::new("/tmp/project with spaces"),
1210 "srv_1",
1211 "team_1",
1212 )
1213 .unwrap();
1214 assert!(unit.text.contains("teams"));
1215 assert!(unit.text.contains("connect"));
1216 assert!(unit.text.contains("work"));
1217 assert!(!unit.text.contains("credential"));
1218 assert!(unit
1219 .path
1220 .file_name()
1221 .unwrap()
1222 .to_string_lossy()
1223 .contains(&connector_service_name("srv_1", "team_1", "work")));
1224 }
1225
1226 #[test]
1227 fn connector_labels_separate_context_and_team() {
1228 assert_ne!(
1229 connector_service_name("srv", "team-a", "work"),
1230 connector_service_name("srv", "team-b", "work")
1231 );
1232 assert_ne!(
1233 connector_service_name("srv", "team-a", "work"),
1234 connector_service_name("srv", "team-a", "personal")
1235 );
1236 }
1237
1238 #[test]
1239 fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
1240 let unsafe_unit = connector_service_unit(
1241 Path::new("/tmp/teams"),
1242 Path::new("/tmp/home"),
1243 Path::new("/tmp/entry"),
1244 "/usr/bin/node",
1245 Path::new("/tmp/supercode"),
1246 "bad\ncontext",
1247 Path::new("/tmp/work"),
1248 "srv",
1249 "team",
1250 );
1251 assert!(unsafe_unit.is_err());
1252 let render = |entry: &str, cwd: &str| {
1253 connector_service_unit(
1254 Path::new("/tmp/teams & logs"),
1255 Path::new("/tmp/home $x"),
1256 Path::new(entry),
1257 "/usr/bin/node",
1258 Path::new("/tmp/super\"code"),
1259 "work",
1260 Path::new(cwd),
1261 "srv",
1262 "team",
1263 )
1264 };
1265 if cfg!(windows) {
1266 assert!(render("/tmp/entry %i", "/tmp/a & b $").is_err());
1268 let unit = render("/tmp/entry", "/tmp/a & b $").unwrap();
1269 assert!(unit.text.contains("--cwd "/tmp/a & b $""));
1270 let (env_path, env_text) = &unit.files[0];
1271 assert!(env_path.ends_with(format!(
1272 "{}.env",
1273 connector_service_name("srv", "team", "work")
1274 )));
1275 assert!(env_text.contains("SUPERCODE_BIN='/tmp/super\"code'\n"));
1276 assert!(!unit.text.contains("super\"code"));
1277 } else if cfg!(target_os = "macos") {
1278 let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1279 assert!(unit.text.contains("&"));
1280 } else {
1281 let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1282 assert!(unit.text.contains("%%"));
1283 assert!(unit.text.contains("$$"));
1284 assert!(unit.text.contains("\\\""));
1285 }
1286 }
1287
1288 #[test]
1289 fn machine_unit_runs_machine_start_against_its_home() {
1290 let unit = service_unit(
1291 Path::new("/tmp/teams & home"),
1292 Path::new("/tmp/sdk/teams/bin/teams.mjs"),
1293 "/usr/bin/node",
1294 )
1295 .unwrap();
1296 assert!(unit.text.contains("machine"));
1297 assert!(unit.text.contains("start"));
1298 assert!(unit.path.ends_with(format!("service/{}", unit_file_name())));
1299 assert!(service_unit(Path::new("/tmp/bad\nhome"), Path::new("/e"), "node").is_err());
1300 if cfg!(windows) {
1301 assert_eq!(unit.kind, "schtasks");
1302 let (env_path, env_text) = &unit.files[0];
1304 assert!(env_path.ends_with(format!("{SERVICE_NAME}.env")));
1305 assert!(env_text.contains("SUPERCODE_TEAMS_HOME='/tmp/teams & home'\n"));
1306 assert!(env_text.contains(&format!("{SERVICE_NAME}.log'\n")));
1307 assert!(unit.text.contains("--headless"));
1308 assert!(unit.text.contains("PT1M"));
1309 assert!(unit
1310 .text
1311 .contains("<WorkingDirectory>/tmp/teams & home</WorkingDirectory>"));
1312 assert!(unit
1313 .text
1314 .contains(r""--env-file=/tmp/teams & home\service\"));
1315 assert!(unit.text.contains("teams.mjs machine start</Arguments>"));
1316 assert!(service_unit(Path::new("/tmp/100%"), Path::new("/e"), "node").is_err());
1317 }
1318 }
1319
1320 #[test]
1321 fn windows_arguments_split_back_out_whole() {
1322 assert_eq!(windows_arg("plain"), "plain");
1323 assert_eq!(windows_arg(""), "\"\"");
1324 assert_eq!(
1325 windows_arg(r"C:\Program Files\nodejs\node.exe"),
1326 r#""C:\Program Files\nodejs\node.exe""#
1327 );
1328 assert_eq!(windows_arg(r#"say "hi""#), r#""say \"hi\"""#);
1329 assert_eq!(windows_arg(r#"a\"b"#), r#""a\\\"b""#);
1331 assert_eq!(windows_arg(r"C:\my dir\"), r#""C:\my dir\\""#);
1332 }
1333
1334 #[test]
1335 fn env_file_values_are_quoted_literally() {
1336 assert_eq!(env_file_value(r"C:\new\tab").unwrap(), r"'C:\new\tab'");
1337 assert_eq!(env_file_value("O'Brien").unwrap(), "`O'Brien`");
1338 assert_eq!(env_file_value("it's `x`").unwrap(), "\"it's `x`\"");
1339 assert!(env_file_value(r"it's `x` \n").is_err());
1340 }
1341}