Skip to main content

supercode_harness/
teams.rs

1//! Where supercode-teams lives on this box, and the service unit that keeps
2//! its machine daemon up (`docs/plans/teams-server.md` §11).
3//!
4//! supercode does not implement teams; the `sdk/teams` package does. This
5//! module holds the two facts the Rust CLI needs about it:
6//!
7//! * **where its Node entry is** — [`teams_entry`], resolved exactly the way
8//!   [`crate::orchestrator::daemon_entry`] resolves the orchestrator's:
9//!   `SUPERCODE_TEAMS_ENTRY` first, then the checkout the running binary sits
10//!   in, then the current directory, then the globally installed
11//!   `@volter-ai-dev/supercode-teams` package (`npm root -g`).
12//! * **what a service unit for its node would say** — [`service_unit`] renders
13//!   the launchd plist / systemd unit that runs `node <entry> machine start`,
14//!   written under `<home>/service/`;
15//!   [`install_service`] and [`uninstall_service`] drive `launchctl` /
16//!   `systemctl --user` over it.
17//!
18//! Everything else about teams — its host key, log, contexts, enrollments — is the Node
19//! package's own state, written by its own CLI. There is no second writer of
20//! that home in this binary.
21
22use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, ServiceState, ServiceUnit};
25
26/// The teams CLI entry inside the `sdk/teams` package.
27pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29/// The npm name the `sdk/teams` package is published under.
30pub const TEAMS_PACKAGE: &str = "@volter-ai-dev/supercode-teams";
31
32/// Directory the rendered service unit is written into, relative to the home.
33pub const SERVICE_DIR: &str = "service";
34
35/// launchd label / systemd unit name for this machine's teams daemon.
36pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-machine";
37
38/// Stable, context-scoped label for one workspace connector service.
39pub fn connector_service_name(server_id: &str, team_id: &str, context: &str) -> String {
40    // FNV-1a is sufficient here: this is a stable filesystem/service label,
41    // not an authorization decision or secret digest.
42    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('&', "&amp;")
53        .replace('<', "&lt;")
54        .replace('>', "&gt;")
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
78/// Render the persistent foreground connector command for one saved context.
79pub 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 suffix = if cfg!(target_os = "macos") {
110        "plist"
111    } else {
112        "service"
113    };
114    let path = teams_home
115        .join(SERVICE_DIR)
116        .join(format!("{label}.{suffix}"));
117    let node = absolute_program(node);
118    let entry = entry_text;
119    let workspace = workspace_text;
120    let home = supercode_home_text;
121    let supercode = supercode_text;
122    if cfg!(target_os = "macos") {
123        let node = plist_text(&node);
124        let entry = plist_text(&entry);
125        let workspace = plist_text(&workspace);
126        let home = plist_text(&home);
127        let supercode = plist_text(&supercode);
128        let context = plist_text(context);
129        let text = format!(
130            r#"<?xml version="1.0" encoding="UTF-8"?>
131<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
132<plist version="1.0"><dict>
133  <key>Label</key><string>{label}</string>
134  <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>
135  <key>EnvironmentVariables</key><dict><key>SUPERCODE_HOME</key><string>{home}</string><key>SUPERCODE_BIN</key><string>{supercode}</string></dict>
136  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
137  <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
138  <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
139</dict></plist>
140"#,
141            plist_text(&teams_home_text),
142            plist_text(&teams_home_text)
143        );
144        Ok(ServiceUnit {
145            kind: "launchd",
146            path: path.clone(),
147            text,
148            install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
149        })
150    } else {
151        let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
152        let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
153        let node = systemd_arg(&node);
154        let entry = systemd_arg(&entry);
155        let workspace = systemd_arg(&workspace);
156        let context_description = context.replace('%', "%%").replace('$', "$$");
157        let context = systemd_arg(context);
158        let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\n\n[Install]\nWantedBy=default.target\n");
159        Ok(ServiceUnit {
160            kind: "systemd",
161            path: path.clone(),
162            text,
163            install_command: format!(
164                "systemctl --user link {} && systemctl --user enable --now {label}",
165                path.display()
166            ),
167        })
168    }
169}
170
171/// Why a teams verb could not do its work.
172#[derive(Debug, thiserror::Error)]
173pub enum TeamsError {
174    /// The Node teams entry could not be located.
175    #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
176    NoEntry {
177        /// The candidate paths that were searched, joined.
178        searched: String,
179    },
180    /// A service manager refused, or there is none on this platform.
181    #[error("teams service: {action} failed: {detail}")]
182    Service {
183        /// What was attempted (`install`, `uninstall`).
184        action: &'static str,
185        /// What the service manager (or this module) said about it.
186        detail: String,
187    },
188    /// A file under the teams home could not be written or removed.
189    #[error("teams file `{}`: {source}", path.display())]
190    File {
191        /// The path involved.
192        path: PathBuf,
193        /// The underlying I/O failure.
194        source: std::io::Error,
195    },
196}
197
198/// The teams home: `SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`.
199///
200/// The same precedence `sdk/teams/home.mjs` uses, so a unit installed from
201/// here serves the home the Node CLI reads.
202pub fn teams_home() -> PathBuf {
203    if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
204        if !home.is_empty() {
205            return PathBuf::from(home);
206        }
207    }
208    crate::agent::global_instructions_dir().join("teams")
209}
210
211/// Locate the Node teams entry (`sdk/teams/bin/teams.mjs`).
212///
213/// Candidates, in order: `SUPERCODE_TEAMS_ENTRY` (an explicit override, which
214/// is also how a test points at a fake), the repo checkout the running binary
215/// sits in, the current directory, the workspace this crate was built from,
216/// and the globally installed npm package — an installed binary has no
217/// checkout, so `npm install -g @volter-ai-dev/supercode-teams` is how a
218/// Machine gets its node.
219pub fn teams_entry() -> Result<PathBuf, TeamsError> {
220    let mut searched = Vec::new();
221    if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
222        let path = PathBuf::from(explicit);
223        if path.is_file() {
224            return Ok(path);
225        }
226        searched.push(path.display().to_string());
227    }
228    let mut roots: Vec<PathBuf> = Vec::new();
229    if let Ok(exe) = std::env::current_exe() {
230        // target/<profile>/supercode → the workspace root is two levels up.
231        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
232    }
233    if let Ok(cwd) = std::env::current_dir() {
234        roots.push(cwd);
235    }
236    // A locally built binary's target directory can live anywhere (a shared
237    // cargo build dir, another volume), so the checkout it was built from is
238    // the last candidate. On an installed binary this path simply does not
239    // exist and is skipped like any other miss.
240    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
241        roots.push(workspace.to_path_buf());
242    }
243    for root in roots {
244        let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
245        if candidate.is_file() {
246            return Ok(candidate);
247        }
248        searched.push(candidate.display().to_string());
249    }
250    if let Some(global) = global_npm_root() {
251        let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
252        if candidate.is_file() {
253            return Ok(candidate);
254        }
255        searched.push(candidate.display().to_string());
256    }
257    Err(TeamsError::NoEntry {
258        searched: searched.join(", "),
259    })
260}
261
262/// Where npm installs global packages (`npm root -g`), when npm is present.
263fn global_npm_root() -> Option<PathBuf> {
264    let output = std::process::Command::new("npm")
265        .args(["root", "-g"])
266        .stdin(std::process::Stdio::null())
267        .stderr(std::process::Stdio::null())
268        .output()
269        .ok()?;
270    if !output.status.success() {
271        return None;
272    }
273    let text = String::from_utf8_lossy(&output.stdout);
274    let root = text.trim();
275    if root.is_empty() {
276        return None;
277    }
278    Some(PathBuf::from(root))
279}
280
281/// Render the per-platform service unit for this machine's teams daemon.
282///
283/// The node takes no `--root`: it serves the home its own environment
284/// resolves (`SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`), so the
285/// unit names the listen address and nothing else. A port of `0` means the
286/// node picks one and publishes it in `<home>/node.json`.
287pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
288    let home_display = home.display().to_string();
289    let entry_display = entry.display().to_string();
290    if cfg!(target_os = "macos") {
291        let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
292        let text = format!(
293            r#"<?xml version="1.0" encoding="UTF-8"?>
294<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
295<plist version="1.0">
296<dict>
297  <key>Label</key><string>{SERVICE_NAME}</string>
298  <key>ProgramArguments</key>
299  <array>
300    <string>{node}</string>
301    <string>{entry_display}</string>
302    <string>machine</string>
303    <string>start</string>
304  </array>
305  <key>EnvironmentVariables</key>
306  <dict>
307    <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
308  </dict>
309  <key>RunAtLoad</key><true/>
310  <key>KeepAlive</key><true/>
311  <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
312  <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
313</dict>
314</plist>
315"#
316        );
317        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
318        ServiceUnit {
319            kind: "launchd",
320            path,
321            text,
322            install_command: install,
323        }
324    } else {
325        let path = home
326            .join(SERVICE_DIR)
327            .join(format!("{SERVICE_NAME}.service"));
328        let text = format!(
329            "[Unit]\n\
330             Description=supercode teams machine daemon ({home_display})\n\
331             After=network.target\n\
332             \n\
333             [Service]\n\
334             Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
335             ExecStart={node} {entry_display} machine start\n\
336             Restart=on-failure\n\
337             KillSignal=SIGTERM\n\
338             \n\
339             [Install]\n\
340             WantedBy=default.target\n"
341        );
342        let install = format!(
343            "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
344            path.display()
345        );
346        ServiceUnit {
347            kind: "systemd",
348            path,
349            text,
350            install_command: install,
351        }
352    }
353}
354
355/// Write a rendered unit under `<home>/service/`.
356pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
357    if let Some(parent) = unit.path.parent() {
358        std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
359            path: unit.path.clone(),
360            source,
361        })?;
362    }
363    std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
364        path: unit.path.clone(),
365        source,
366    })
367}
368
369/// Run a service-manager command and return (success, stdout+stderr).
370fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
371    let output = std::process::Command::new(program).args(args).output()?;
372    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
373    text.push_str(&String::from_utf8_lossy(&output.stderr));
374    Ok((output.status.success(), text.trim().to_string()))
375}
376
377#[cfg(target_os = "macos")]
378fn gui_domain() -> String {
379    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
380    format!("gui/{}", unsafe { libc::getuid() })
381}
382
383/// What the platform's service manager says about the teams daemon unit.
384///
385/// Never starts or installs anything.
386pub fn service_status() -> ServiceState {
387    platform_status()
388}
389
390#[cfg(target_os = "macos")]
391fn platform_status() -> ServiceState {
392    let label = SERVICE_NAME.to_string();
393    let target = format!("{}/{SERVICE_NAME}", gui_domain());
394    match run_tool("launchctl", &["print", &target]) {
395        Ok((true, text)) => ServiceState {
396            kind: "launchd",
397            label,
398            installed: true,
399            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
400            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
401        },
402        Ok((false, _)) => ServiceState {
403            kind: "launchd",
404            label,
405            installed: false,
406            pid: None,
407            detail: format!("not bootstrapped in {}", gui_domain()),
408        },
409        Err(error) => ServiceState {
410            kind: "launchd",
411            label,
412            installed: false,
413            pid: None,
414            detail: format!("launchctl unavailable: {error}"),
415        },
416    }
417}
418
419#[cfg(all(unix, not(target_os = "macos")))]
420fn platform_status() -> ServiceState {
421    let label = SERVICE_NAME.to_string();
422    match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
423        Ok((active, text)) => {
424            let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
425                .map(|(ok, _)| ok)
426                .unwrap_or(false);
427            ServiceState {
428                kind: "systemd",
429                label,
430                installed: active || known,
431                pid: None,
432                detail: if text.is_empty() {
433                    "unknown".into()
434                } else {
435                    text
436                },
437            }
438        }
439        Err(error) => ServiceState {
440            kind: "systemd",
441            label,
442            installed: false,
443            pid: None,
444            detail: format!("systemctl unavailable: {error}"),
445        },
446    }
447}
448
449#[cfg(not(unix))]
450fn platform_status() -> ServiceState {
451    ServiceState {
452        kind: "none",
453        label: SERVICE_NAME.to_string(),
454        installed: false,
455        pid: None,
456        detail: "no service manager on this platform".into(),
457    }
458}
459
460/// `key = value` out of a service manager's block output.
461#[cfg(target_os = "macos")]
462fn field_of(text: &str, key: &str) -> Option<String> {
463    text.lines()
464        .find_map(|line| line.trim().strip_prefix(key))
465        .map(|value| value.trim().to_string())
466}
467
468/// The file name the unit takes on this platform.
469fn unit_file_name() -> String {
470    if cfg!(target_os = "macos") {
471        format!("{SERVICE_NAME}.plist")
472    } else {
473        format!("{SERVICE_NAME}.service")
474    }
475}
476
477/// Render the unit, hand it to the platform's service manager, and start it.
478///
479/// Refuses a label the manager already holds rather than replacing it: two
480/// homes share one label, so an install that silently took it over would point
481/// a running node at a different folder.
482pub fn install_service(
483    home: &Path,
484    entry: &Path,
485    node: &str,
486) -> Result<(ServiceUnit, ServiceState), TeamsError> {
487    let existing = service_status();
488    if existing.installed {
489        return Err(TeamsError::Service {
490            action: "install",
491            detail: format!(
492                "`{}` is already installed ({}); `supercode teams machine uninstall` first",
493                existing.label, existing.detail
494            ),
495        });
496    }
497    let unit = service_unit(home, entry, &absolute_program(node));
498    write_unit(&unit)?;
499    platform_install(&unit)?;
500    Ok((unit, service_status()))
501}
502
503/// Install a rendered context connector. An identical installed unit is an
504/// idempotent success; a different unit under the same label is refused.
505pub fn install_connector_service(
506    unit: &ServiceUnit,
507    label: &str,
508) -> Result<ServiceState, TeamsError> {
509    let existing = named_service_status(label);
510    if unit.path.exists() {
511        let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
512            path: unit.path.clone(),
513            source,
514        })?;
515        if old != unit.text {
516            return Err(TeamsError::Service { action: "install", detail: format!("`{label}` exists with different context or service parameters; disconnect it first") });
517        }
518        if existing.installed {
519            return Ok(existing);
520        }
521    } else if existing.installed {
522        return Err(TeamsError::Service {
523            action: "install",
524            detail: format!(
525                "service manager already owns `{label}` without its expected unit file"
526            ),
527        });
528    }
529    write_unit(unit)?;
530    named_platform_install(unit, label)?;
531    Ok(named_service_status(label))
532}
533
534/// Stop and remove exactly one context connector service.
535pub fn uninstall_connector_service(
536    teams_home: &Path,
537    label: &str,
538) -> Result<ServiceState, TeamsError> {
539    named_platform_uninstall(label)?;
540    let suffix = if cfg!(target_os = "macos") {
541        "plist"
542    } else {
543        "service"
544    };
545    let path = teams_home
546        .join(SERVICE_DIR)
547        .join(format!("{label}.{suffix}"));
548    match std::fs::remove_file(&path) {
549        Ok(()) => {}
550        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
551        Err(source) => return Err(TeamsError::File { path, source }),
552    }
553    Ok(named_service_status(label))
554}
555
556/// Read-only service-manager status for one context connector.
557pub fn connector_service_status(label: &str) -> ServiceState {
558    named_service_status(label)
559}
560
561#[cfg(target_os = "macos")]
562fn named_service_status(label: &str) -> ServiceState {
563    let target = format!("{}/{label}", gui_domain());
564    match run_tool("launchctl", &["print", &target]) {
565        Ok((true, text)) => ServiceState {
566            kind: "launchd",
567            label: label.into(),
568            installed: true,
569            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
570            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
571        },
572        Ok((false, _)) => ServiceState {
573            kind: "launchd",
574            label: label.into(),
575            installed: false,
576            pid: None,
577            detail: format!("not bootstrapped in {}", gui_domain()),
578        },
579        Err(error) => ServiceState {
580            kind: "launchd",
581            label: label.into(),
582            installed: false,
583            pid: None,
584            detail: format!("launchctl unavailable: {error}"),
585        },
586    }
587}
588
589#[cfg(all(unix, not(target_os = "macos")))]
590fn named_service_status(label: &str) -> ServiceState {
591    match run_tool("systemctl", &["--user", "is-active", label]) {
592        Ok((active, text)) => {
593            let known = run_tool("systemctl", &["--user", "is-enabled", label])
594                .map(|(ok, _)| ok)
595                .unwrap_or(false);
596            ServiceState {
597                kind: "systemd",
598                label: label.into(),
599                installed: active || known,
600                pid: None,
601                detail: if text.is_empty() {
602                    "unknown".into()
603                } else {
604                    text
605                },
606            }
607        }
608        Err(error) => ServiceState {
609            kind: "systemd",
610            label: label.into(),
611            installed: false,
612            pid: None,
613            detail: format!("systemctl unavailable: {error}"),
614        },
615    }
616}
617
618#[cfg(not(unix))]
619fn named_service_status(label: &str) -> ServiceState {
620    ServiceState {
621        kind: "none",
622        label: label.into(),
623        installed: false,
624        pid: None,
625        detail: "no service manager on this platform".into(),
626    }
627}
628
629#[cfg(target_os = "macos")]
630fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
631    platform_install(unit)
632}
633#[cfg(all(unix, not(target_os = "macos")))]
634fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
635    let path = unit.path.display().to_string();
636    for args in [
637        vec!["--user", "link", path.as_str()],
638        vec!["--user", "enable", "--now", label],
639    ] {
640        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
641            action: "install",
642            detail: format!("systemctl: {error}"),
643        })?;
644        if !ok {
645            return Err(TeamsError::Service {
646                action: "install",
647                detail: format!("systemctl {}: {text}", args.join(" ")),
648            });
649        }
650    }
651    Ok(())
652}
653#[cfg(not(unix))]
654fn named_platform_install(_unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
655    Err(TeamsError::Service {
656        action: "install",
657        detail: "no service manager on this platform".into(),
658    })
659}
660
661#[cfg(target_os = "macos")]
662fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
663    let target = format!("{}/{label}", gui_domain());
664    let (ok, text) =
665        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
666            action: "uninstall",
667            detail: format!("launchctl: {error}"),
668        })?;
669    if !ok && !text.contains("No such process") && !text.contains("not find") {
670        return Err(TeamsError::Service {
671            action: "uninstall",
672            detail: format!("launchctl bootout {target}: {text}"),
673        });
674    }
675    Ok(())
676}
677#[cfg(all(unix, not(target_os = "macos")))]
678fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
679    let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
680    Ok(())
681}
682#[cfg(not(unix))]
683fn named_platform_uninstall(_label: &str) -> Result<(), TeamsError> {
684    Ok(())
685}
686
687#[cfg(target_os = "macos")]
688fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
689    let path = unit.path.display().to_string();
690    let (ok, text) =
691        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
692            TeamsError::Service {
693                action: "install",
694                detail: format!("launchctl: {error}"),
695            }
696        })?;
697    if !ok {
698        return Err(TeamsError::Service {
699            action: "install",
700            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
701        });
702    }
703    Ok(())
704}
705
706/// Untested on this box (the receipt is macOS); these are the commands
707/// `service_unit` prints as its `install_command`.
708#[cfg(all(unix, not(target_os = "macos")))]
709fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
710    let path = unit.path.display().to_string();
711    for args in [
712        vec!["--user", "link", path.as_str()],
713        vec!["--user", "enable", "--now", SERVICE_NAME],
714    ] {
715        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
716            action: "install",
717            detail: format!("systemctl: {error}"),
718        })?;
719        if !ok {
720            return Err(TeamsError::Service {
721                action: "install",
722                detail: format!("systemctl {}: {text}", args.join(" ")),
723            });
724        }
725    }
726    Ok(())
727}
728
729#[cfg(not(unix))]
730fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
731    Err(TeamsError::Service {
732        action: "install",
733        detail: "no service manager on this platform".into(),
734    })
735}
736
737/// Stop and unregister the unit, and remove the rendered file.
738///
739/// Idempotent: a unit the manager does not hold is not an error, because the
740/// state the operator asked for is the state they get.
741pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
742    platform_uninstall()?;
743    let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
744    match std::fs::remove_file(&unit_path) {
745        Ok(()) => {}
746        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
747        Err(source) => {
748            return Err(TeamsError::File {
749                path: unit_path,
750                source,
751            })
752        }
753    }
754    // `launchctl bootout` returns before the job is torn down, so the state
755    // this reports is the settled one, not the manager mid-teardown.
756    let mut state = service_status();
757    for _ in 0..40 {
758        if !state.installed {
759            break;
760        }
761        std::thread::sleep(std::time::Duration::from_millis(100));
762        state = service_status();
763    }
764    Ok(state)
765}
766
767#[cfg(target_os = "macos")]
768fn platform_uninstall() -> Result<(), TeamsError> {
769    let target = format!("{}/{SERVICE_NAME}", gui_domain());
770    let (ok, text) =
771        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
772            action: "uninstall",
773            detail: format!("launchctl: {error}"),
774        })?;
775    // `bootout` on a label nobody holds says so and exits non-zero.
776    if !ok && !text.contains("No such process") && !text.contains("not find") {
777        return Err(TeamsError::Service {
778            action: "uninstall",
779            detail: format!("launchctl bootout {target}: {text}"),
780        });
781    }
782    Ok(())
783}
784
785#[cfg(all(unix, not(target_os = "macos")))]
786fn platform_uninstall() -> Result<(), TeamsError> {
787    let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
788    Ok(())
789}
790
791#[cfg(not(unix))]
792fn platform_uninstall() -> Result<(), TeamsError> {
793    Ok(())
794}
795
796#[cfg(test)]
797mod connector_service_tests {
798    use super::*;
799
800    #[test]
801    fn connector_unit_is_context_scoped_and_contains_no_credential() {
802        let unit = connector_service_unit(
803            Path::new("/tmp/teams home"),
804            Path::new("/tmp/supercode home"),
805            Path::new("/tmp/sdk/teams/bin/teams.mjs"),
806            "/usr/bin/node",
807            Path::new("/tmp/bin/supercode"),
808            "work",
809            Path::new("/tmp/project with spaces"),
810            "srv_1",
811            "team_1",
812        )
813        .unwrap();
814        assert!(unit.text.contains("teams"));
815        assert!(unit.text.contains("connect"));
816        assert!(unit.text.contains("work"));
817        assert!(!unit.text.contains("credential"));
818        assert!(unit
819            .path
820            .file_name()
821            .unwrap()
822            .to_string_lossy()
823            .contains(&connector_service_name("srv_1", "team_1", "work")));
824    }
825
826    #[test]
827    fn connector_labels_separate_context_and_team() {
828        assert_ne!(
829            connector_service_name("srv", "team-a", "work"),
830            connector_service_name("srv", "team-b", "work")
831        );
832        assert_ne!(
833            connector_service_name("srv", "team-a", "work"),
834            connector_service_name("srv", "team-a", "personal")
835        );
836    }
837
838    #[test]
839    fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
840        let unsafe_unit = connector_service_unit(
841            Path::new("/tmp/teams"),
842            Path::new("/tmp/home"),
843            Path::new("/tmp/entry"),
844            "/usr/bin/node",
845            Path::new("/tmp/supercode"),
846            "bad\ncontext",
847            Path::new("/tmp/work"),
848            "srv",
849            "team",
850        );
851        assert!(unsafe_unit.is_err());
852        let unit = connector_service_unit(
853            Path::new("/tmp/teams & logs"),
854            Path::new("/tmp/home $x"),
855            Path::new("/tmp/entry %i"),
856            "/usr/bin/node",
857            Path::new("/tmp/super\"code"),
858            "work",
859            Path::new("/tmp/a & b % $"),
860            "srv",
861            "team",
862        )
863        .unwrap();
864        if cfg!(target_os = "macos") {
865            assert!(unit.text.contains("&amp;"));
866        } else {
867            assert!(unit.text.contains("%%"));
868            assert!(unit.text.contains("$$"));
869            assert!(unit.text.contains("\\\""));
870        }
871    }
872}