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 checkout it was built from, then the globally installed
11//!   `@volter/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, resolve_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/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 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        // A scheduled task sets no environment and keeps no output, so node reads both from a file beside the
119        // task (`--env-file`); the user's own PATH is the task's. `conhost --headless` runs it without a window,
120        // and hides node's exit code too, so restart-on-failure never sees one fail: a trigger every minute
121        // starts the connector again instead, and while it runs the task's IgnoreNew makes that tick a no-op.
122        // The trigger's fixed past start keeps the rendered unit the same on every install.
123        let env_path = connector_env_path(teams_home, &label);
124        let log_path = teams_home
125            .join(SERVICE_DIR)
126            .join(format!("{label}.log"))
127            .display()
128            .to_string();
129        let env_text = [
130            ("SUPERCODE_HOME", home.as_str()),
131            ("SUPERCODE_BIN", supercode.as_str()),
132            ("SUPERCODE_TEAMS_LOG", log_path.as_str()),
133        ]
134        .iter()
135        .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
136        .collect::<Result<String, _>>()?;
137        // Task Scheduler expands `%NAME%` in a task's arguments, and there is no escape for it.
138        for value in [&node, &entry, &workspace, &env_path.display().to_string()] {
139            if value.contains('%') {
140                return Err(TeamsError::Service {
141                    action: "render",
142                    detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
143                });
144            }
145        }
146        let arguments = [
147            node.as_str(),
148            &format!("--env-file={}", env_path.display()),
149            entry.as_str(),
150            "teams",
151            "connect",
152            "--foreground",
153            "--context",
154            context,
155            "--cwd",
156            workspace.as_str(),
157        ]
158        .iter()
159        .map(|argument| windows_arg(argument))
160        .collect::<Vec<_>>()
161        .join(" ");
162        let user = windows_user();
163        let conhost =
164            Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
165                .join(r"System32\conhost.exe")
166                .display()
167                .to_string();
168        let text = format!(
169            r#"<?xml version="1.0" encoding="UTF-16"?>
170<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
171  <RegistrationInfo><Description>supercode Teams connector ({})</Description></RegistrationInfo>
172  <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>
173  <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
174  <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>
175  <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
176</Task>
177"#,
178            xml_text(context),
179            xml_text(&user),
180            xml_text(&user),
181            xml_text(&conhost),
182            xml_text(&arguments),
183            xml_text(&workspace),
184        );
185        return Ok(ServiceUnit {
186            kind: "schtasks",
187            path: path.clone(),
188            text,
189            install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
190            files: vec![(env_path, env_text)],
191        });
192    }
193    if cfg!(target_os = "macos") {
194        let node = plist_text(&node);
195        let entry = plist_text(&entry);
196        let workspace = plist_text(&workspace);
197        let home = plist_text(&home);
198        let supercode = plist_text(&supercode);
199        let context = plist_text(context);
200        let search_path = plist_text(&service_path());
201        let text = format!(
202            r#"<?xml version="1.0" encoding="UTF-8"?>
203<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
204<plist version="1.0"><dict>
205  <key>Label</key><string>{label}</string>
206  <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>
207  <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>
208  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
209  <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
210  <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
211</dict></plist>
212"#,
213            plist_text(&teams_home_text),
214            plist_text(&teams_home_text)
215        );
216        Ok(ServiceUnit {
217            kind: "launchd",
218            path: path.clone(),
219            text,
220            install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
221            files: Vec::new(),
222        })
223    } else {
224        let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
225        let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
226        let environment_path = systemd_arg(&format!("PATH={}", service_path()));
227        let node = systemd_arg(&node);
228        let entry = systemd_arg(&entry);
229        let workspace = systemd_arg(&workspace);
230        let context_description = context.replace('%', "%%").replace('$', "$$");
231        let context = systemd_arg(context);
232        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");
233        Ok(ServiceUnit {
234            kind: "systemd",
235            path: path.clone(),
236            text,
237            install_command: format!(
238                "systemctl --user link {} && systemctl --user enable --now {label}",
239                path.display()
240            ),
241            files: Vec::new(),
242        })
243    }
244}
245
246/// The connector unit's file suffix on this platform.
247fn connector_unit_suffix() -> &'static str {
248    if cfg!(windows) {
249        "xml"
250    } else if cfg!(target_os = "macos") {
251        "plist"
252    } else {
253        "service"
254    }
255}
256
257/// The environment file a Windows connector task hands to node (`--env-file`).
258fn connector_env_path(teams_home: &Path, label: &str) -> PathBuf {
259    teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
260}
261
262/// Text inside a Task Scheduler XML element or attribute.
263fn xml_text(value: &str) -> String {
264    plist_text(value).replace('"', "&quot;")
265}
266
267/// One argument of a Windows command line, quoted so the C runtime (node.exe's) splits it back out whole:
268/// backslashes are literal except before a quote, where they and the quote are escaped.
269fn windows_arg(value: &str) -> String {
270    if !value.is_empty() && !value.contains([' ', '\t', '"']) {
271        return value.to_string();
272    }
273    let mut quoted = String::from("\"");
274    let mut backslashes = 0;
275    for character in value.chars() {
276        match character {
277            '\\' => backslashes += 1,
278            '"' => {
279                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
280                quoted.push('"');
281                backslashes = 0;
282            }
283            other => {
284                quoted.push_str(&"\\".repeat(backslashes));
285                quoted.push(other);
286                backslashes = 0;
287            }
288        }
289    }
290    quoted.push_str(&"\\".repeat(backslashes * 2));
291    quoted.push('"');
292    quoted
293}
294
295/// A value in a node `--env-file`, quoted with a quote character the value does not contain. Single and backtick
296/// quotes are literal; double quotes would turn a path's `\n` into a newline, so they are the last choice.
297fn env_file_value(value: &str) -> Result<String, TeamsError> {
298    ['\'', '`']
299        .into_iter()
300        .find(|quote| !value.contains(*quote))
301        .map(|quote| format!("{quote}{value}{quote}"))
302        .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
303        .ok_or_else(|| TeamsError::Service {
304            action: "render",
305            detail: format!("cannot write `{value}` into the connector's environment file"),
306        })
307}
308
309/// The Windows account a task runs as: `DOMAIN\user`, the one installing it.
310fn windows_user() -> String {
311    let user = std::env::var("USERNAME").unwrap_or_default();
312    match std::env::var("USERDOMAIN") {
313        Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
314        _ => user,
315    }
316}
317
318/// Why a teams verb could not do its work.
319#[derive(Debug, thiserror::Error)]
320pub enum TeamsError {
321    /// The Node teams entry could not be located.
322    #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
323    NoEntry {
324        /// The candidate paths that were searched, joined.
325        searched: String,
326    },
327    /// A service manager refused, or there is none on this platform.
328    #[error("teams service: {action} failed: {detail}")]
329    Service {
330        /// What was attempted (`install`, `uninstall`).
331        action: &'static str,
332        /// What the service manager (or this module) said about it.
333        detail: String,
334    },
335    /// A file under the teams home could not be written or removed.
336    #[error("teams file `{}`: {source}", path.display())]
337    File {
338        /// The path involved.
339        path: PathBuf,
340        /// The underlying I/O failure.
341        source: std::io::Error,
342    },
343}
344
345/// The search path a service runs with: the installing shell's own, so a
346/// service finds the same `tmux`, `node` and harness CLIs its installer did
347/// (a launchd or systemd default path has none of them).
348fn service_path() -> String {
349    std::env::var("PATH")
350        .ok()
351        .filter(|path| !path.trim().is_empty())
352        .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
353}
354
355/// The teams home: `SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`.
356///
357/// The same precedence `sdk/volter-teams/home.mjs` uses, so a unit installed from
358/// here serves the home the Node CLI reads.
359pub fn teams_home() -> PathBuf {
360    if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
361        if !home.is_empty() {
362            return PathBuf::from(home);
363        }
364    }
365    crate::agent::global_instructions_dir().join("teams")
366}
367
368/// Locate the Node teams entry (`sdk/teams/bin/teams.mjs`).
369///
370/// Candidates, in order: `SUPERCODE_TEAMS_ENTRY` (an explicit override, which
371/// is also how a test points at a fake), the repo checkout the running binary
372/// sits in, the workspace this crate was built from,
373/// and the globally installed npm package — an installed binary has no
374/// checkout, so `npm install -g @volter/supercode-teams` is how a
375/// Machine gets its node. The current directory is never a candidate: the
376/// code a binary runs does not change with where it is run.
377pub fn teams_entry() -> Result<PathBuf, TeamsError> {
378    let mut searched = Vec::new();
379    if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
380        let path = PathBuf::from(explicit);
381        if path.is_file() {
382            return Ok(path);
383        }
384        searched.push(path.display().to_string());
385    }
386    let mut roots: Vec<PathBuf> = Vec::new();
387    if let Ok(exe) = std::env::current_exe() {
388        // target/<profile>/supercode → the workspace root is two levels up.
389        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
390    }
391    // A locally built binary's target directory can live anywhere (a shared
392    // cargo build dir, another volume), so the checkout it was built from is
393    // the last candidate. On an installed binary this path simply does not
394    // exist and is skipped like any other miss.
395    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
396        roots.push(workspace.to_path_buf());
397    }
398    for root in roots {
399        let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
400        if candidate.is_file() {
401            return Ok(candidate);
402        }
403        searched.push(candidate.display().to_string());
404    }
405    // Installed from npm, this binary sits inside the global node_modules that
406    // also holds the Teams package, so that directory is found from the
407    // binary's own path first (`npm root -g` masks path segments it takes for
408    // secrets, a UUID among them).
409    if let Ok(exe) = std::env::current_exe() {
410        for modules in exe
411            .ancestors()
412            .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
413        {
414            let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
415            if candidate.is_file() {
416                return Ok(candidate);
417            }
418            searched.push(candidate.display().to_string());
419        }
420    }
421    if let Some(global) = global_npm_root() {
422        let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
423        if candidate.is_file() {
424            return Ok(candidate);
425        }
426        searched.push(candidate.display().to_string());
427    }
428    Err(TeamsError::NoEntry {
429        searched: searched.join(", "),
430    })
431}
432
433/// Where npm installs global packages (`npm root -g`), when npm is present.
434fn global_npm_root() -> Option<PathBuf> {
435    let output = std::process::Command::new(resolve_program("npm"))
436        .args(["root", "-g"])
437        .stdin(std::process::Stdio::null())
438        .stderr(std::process::Stdio::null())
439        .output()
440        .ok()?;
441    if !output.status.success() {
442        return None;
443    }
444    let text = String::from_utf8_lossy(&output.stdout);
445    let root = text.trim();
446    if root.is_empty() {
447        return None;
448    }
449    Some(PathBuf::from(root))
450}
451
452/// Render the per-platform service unit for this machine's teams daemon.
453///
454/// The node takes no `--root`: it serves the home its own environment
455/// resolves (`SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`), so the
456/// unit names the listen address and nothing else. A port of `0` means the
457/// node picks one and publishes it in `<home>/node.json`.
458pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
459    let home_display = home.display().to_string();
460    let entry_display = entry.display().to_string();
461    if cfg!(target_os = "macos") {
462        let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
463        let text = format!(
464            r#"<?xml version="1.0" encoding="UTF-8"?>
465<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
466<plist version="1.0">
467<dict>
468  <key>Label</key><string>{SERVICE_NAME}</string>
469  <key>ProgramArguments</key>
470  <array>
471    <string>{node}</string>
472    <string>{entry_display}</string>
473    <string>machine</string>
474    <string>start</string>
475  </array>
476  <key>EnvironmentVariables</key>
477  <dict>
478    <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
479  </dict>
480  <key>RunAtLoad</key><true/>
481  <key>KeepAlive</key><true/>
482  <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
483  <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
484</dict>
485</plist>
486"#
487        );
488        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
489        ServiceUnit {
490            kind: "launchd",
491            path,
492            text,
493            install_command: install,
494            files: Vec::new(),
495        }
496    } else {
497        let path = home
498            .join(SERVICE_DIR)
499            .join(format!("{SERVICE_NAME}.service"));
500        let text = format!(
501            "[Unit]\n\
502             Description=supercode teams machine daemon ({home_display})\n\
503             After=network.target\n\
504             \n\
505             [Service]\n\
506             Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
507             ExecStart={node} {entry_display} machine start\n\
508             Restart=on-failure\n\
509             KillSignal=SIGTERM\n\
510             \n\
511             [Install]\n\
512             WantedBy=default.target\n"
513        );
514        let install = format!(
515            "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
516            path.display()
517        );
518        ServiceUnit {
519            kind: "systemd",
520            path,
521            text,
522            install_command: install,
523            files: Vec::new(),
524        }
525    }
526}
527
528/// Write a rendered unit under `<home>/service/`.
529pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
530    if let Some(parent) = unit.path.parent() {
531        std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
532            path: unit.path.clone(),
533            source,
534        })?;
535    }
536    std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
537        path: unit.path.clone(),
538        source,
539    })?;
540    for (path, text) in &unit.files {
541        std::fs::write(path, text).map_err(|source| TeamsError::File {
542            path: path.clone(),
543            source,
544        })?;
545    }
546    Ok(())
547}
548
549/// Run a service-manager command and return (success, stdout+stderr).
550fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
551    let output = std::process::Command::new(program).args(args).output()?;
552    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
553    text.push_str(&String::from_utf8_lossy(&output.stderr));
554    Ok((output.status.success(), text.trim().to_string()))
555}
556
557#[cfg(target_os = "macos")]
558fn gui_domain() -> String {
559    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
560    format!("gui/{}", unsafe { libc::getuid() })
561}
562
563/// What the platform's service manager says about the teams daemon unit.
564///
565/// Never starts or installs anything.
566pub fn service_status() -> ServiceState {
567    platform_status()
568}
569
570#[cfg(target_os = "macos")]
571fn platform_status() -> ServiceState {
572    let label = SERVICE_NAME.to_string();
573    let target = format!("{}/{SERVICE_NAME}", gui_domain());
574    match run_tool("launchctl", &["print", &target]) {
575        Ok((true, text)) => ServiceState {
576            kind: "launchd",
577            label,
578            installed: true,
579            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
580            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
581        },
582        Ok((false, _)) => ServiceState {
583            kind: "launchd",
584            label,
585            installed: false,
586            pid: None,
587            detail: format!("not bootstrapped in {}", gui_domain()),
588        },
589        Err(error) => ServiceState {
590            kind: "launchd",
591            label,
592            installed: false,
593            pid: None,
594            detail: format!("launchctl unavailable: {error}"),
595        },
596    }
597}
598
599#[cfg(all(unix, not(target_os = "macos")))]
600fn platform_status() -> ServiceState {
601    let label = SERVICE_NAME.to_string();
602    match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
603        Ok((active, text)) => {
604            let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
605                .map(|(ok, _)| ok)
606                .unwrap_or(false);
607            ServiceState {
608                kind: "systemd",
609                label,
610                installed: active || known,
611                pid: None,
612                detail: if text.is_empty() {
613                    "unknown".into()
614                } else {
615                    text
616                },
617            }
618        }
619        Err(error) => ServiceState {
620            kind: "systemd",
621            label,
622            installed: false,
623            pid: None,
624            detail: format!("systemctl unavailable: {error}"),
625        },
626    }
627}
628
629#[cfg(not(unix))]
630fn platform_status() -> ServiceState {
631    ServiceState {
632        kind: "none",
633        label: SERVICE_NAME.to_string(),
634        installed: false,
635        pid: None,
636        detail: "no service manager on this platform".into(),
637    }
638}
639
640/// `key = value` out of a service manager's block output.
641#[cfg(target_os = "macos")]
642fn field_of(text: &str, key: &str) -> Option<String> {
643    text.lines()
644        .find_map(|line| line.trim().strip_prefix(key))
645        .map(|value| value.trim().to_string())
646}
647
648/// The file name the unit takes on this platform.
649fn unit_file_name() -> String {
650    if cfg!(target_os = "macos") {
651        format!("{SERVICE_NAME}.plist")
652    } else {
653        format!("{SERVICE_NAME}.service")
654    }
655}
656
657/// Render the unit, hand it to the platform's service manager, and start it.
658///
659/// Refuses a label the manager already holds rather than replacing it: two
660/// homes share one label, so an install that silently took it over would point
661/// a running node at a different folder.
662pub fn install_service(
663    home: &Path,
664    entry: &Path,
665    node: &str,
666) -> Result<(ServiceUnit, ServiceState), TeamsError> {
667    let existing = service_status();
668    if existing.installed {
669        return Err(TeamsError::Service {
670            action: "install",
671            detail: format!(
672                "`{}` is already installed ({}); `supercode teams machine uninstall` first",
673                existing.label, existing.detail
674            ),
675        });
676    }
677    let unit = service_unit(home, entry, &absolute_program(node));
678    write_unit(&unit)?;
679    platform_install(&unit)?;
680    Ok((unit, service_status()))
681}
682
683/// Install a rendered context connector. An identical installed unit is an
684/// idempotent success. A different unit in this home's own service folder is
685/// this home's connector with new parameters (a new build, PATH or folder), so
686/// it is replaced and restarted; a label the manager holds with no unit here
687/// belongs to another home and is refused.
688pub fn install_connector_service(
689    unit: &ServiceUnit,
690    label: &str,
691) -> Result<ServiceState, TeamsError> {
692    let existing = named_service_status(label);
693    if unit.path.exists() {
694        let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
695            path: unit.path.clone(),
696            source,
697        })?;
698        // A Windows unit's environment lives in its companion file, so that is compared too.
699        let same = old == unit.text
700            && unit
701                .files
702                .iter()
703                .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
704        if same && existing.installed {
705            return Ok(existing);
706        }
707        if !same && existing.installed {
708            named_platform_uninstall(label)?;
709        }
710    } else if existing.installed {
711        return Err(TeamsError::Service {
712            action: "install",
713            detail: format!(
714                "service manager already owns `{label}` without its expected unit file"
715            ),
716        });
717    }
718    write_unit(unit)?;
719    named_platform_install(unit, label)?;
720    Ok(named_service_status(label))
721}
722
723/// Give every installed harness supercode's messaging tools: register
724/// `<supercode> message mcp` as a user-scope MCP server named `supercode`
725/// through each harness's own `mcp add`. A harness whose CLI is absent, or
726/// that already has the entry, is left as it is. One line per harness says
727/// what happened.
728pub fn register_message_tools(supercode: &Path) -> Vec<String> {
729    let program = supercode.display().to_string();
730    let mut report = Vec::new();
731    for (harness, get, add) in [
732        (
733            "claude",
734            vec!["mcp", "get", "supercode"],
735            vec![
736                "mcp",
737                "add",
738                "--scope",
739                "user",
740                "supercode",
741                "--",
742                &program,
743                "message",
744                "mcp",
745            ],
746        ),
747        (
748            "codex",
749            vec!["mcp", "get", "supercode"],
750            vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
751        ),
752    ] {
753        let run = |args: &[&str]| {
754            std::process::Command::new(resolve_program(harness))
755                .args(args)
756                .stdin(std::process::Stdio::null())
757                .output()
758        };
759        match run(&get) {
760            Err(_) => report.push(format!("{harness}: not installed")),
761            Ok(found) if found.status.success() => {
762                report.push(format!("{harness}: already has supercode's tools"))
763            }
764            Ok(_) => match run(&add) {
765                Ok(added) if added.status.success() => report.push(format!(
766                    "{harness}: supercode's tools added (new sessions load them)"
767                )),
768                Ok(added) => report.push(format!(
769                    "{harness}: could not add supercode's tools: {}",
770                    String::from_utf8_lossy(&added.stderr).trim()
771                )),
772                Err(error) => report.push(format!(
773                    "{harness}: could not add supercode's tools: {error}"
774                )),
775            },
776        }
777    }
778    report
779}
780
781/// Stop and remove exactly one context connector service.
782pub fn uninstall_connector_service(
783    teams_home: &Path,
784    label: &str,
785) -> Result<ServiceState, TeamsError> {
786    named_platform_uninstall(label)?;
787    let unit = teams_home
788        .join(SERVICE_DIR)
789        .join(format!("{label}.{}", connector_unit_suffix()));
790    for path in [unit, connector_env_path(teams_home, label)] {
791        match std::fs::remove_file(&path) {
792            Ok(()) => {}
793            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
794            Err(source) => return Err(TeamsError::File { path, source }),
795        }
796    }
797    Ok(named_service_status(label))
798}
799
800/// Read-only service-manager status for one context connector.
801pub fn connector_service_status(label: &str) -> ServiceState {
802    named_service_status(label)
803}
804
805#[cfg(target_os = "macos")]
806fn named_service_status(label: &str) -> ServiceState {
807    let target = format!("{}/{label}", gui_domain());
808    match run_tool("launchctl", &["print", &target]) {
809        Ok((true, text)) => ServiceState {
810            kind: "launchd",
811            label: label.into(),
812            installed: true,
813            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
814            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
815        },
816        Ok((false, _)) => ServiceState {
817            kind: "launchd",
818            label: label.into(),
819            installed: false,
820            pid: None,
821            detail: format!("not bootstrapped in {}", gui_domain()),
822        },
823        Err(error) => ServiceState {
824            kind: "launchd",
825            label: label.into(),
826            installed: false,
827            pid: None,
828            detail: format!("launchctl unavailable: {error}"),
829        },
830    }
831}
832
833#[cfg(all(unix, not(target_os = "macos")))]
834fn named_service_status(label: &str) -> ServiceState {
835    match run_tool("systemctl", &["--user", "is-active", label]) {
836        Ok((active, text)) => {
837            let known = run_tool("systemctl", &["--user", "is-enabled", label])
838                .map(|(ok, _)| ok)
839                .unwrap_or(false);
840            ServiceState {
841                kind: "systemd",
842                label: label.into(),
843                installed: active || known,
844                pid: None,
845                detail: if text.is_empty() {
846                    "unknown".into()
847                } else {
848                    text
849                },
850            }
851        }
852        Err(error) => ServiceState {
853            kind: "systemd",
854            label: label.into(),
855            installed: false,
856            pid: None,
857            detail: format!("systemctl unavailable: {error}"),
858        },
859    }
860}
861
862#[cfg(not(unix))]
863fn named_service_status(label: &str) -> ServiceState {
864    match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
865        // `"TaskName","Next Run Time","Status"`: the last field is the task's state, in the system's language.
866        Ok((true, text)) => ServiceState {
867            kind: "schtasks",
868            label: label.into(),
869            installed: true,
870            pid: None,
871            detail: text
872                .lines()
873                .next()
874                .and_then(|line| line.rsplit(',').next())
875                .map(|state| state.trim_matches('"').to_string())
876                .filter(|state| !state.is_empty())
877                .unwrap_or_else(|| "registered".into()),
878        },
879        Ok((false, _)) => ServiceState {
880            kind: "schtasks",
881            label: label.into(),
882            installed: false,
883            pid: None,
884            detail: "no scheduled task".into(),
885        },
886        Err(error) => ServiceState {
887            kind: "schtasks",
888            label: label.into(),
889            installed: false,
890            pid: None,
891            detail: format!("schtasks unavailable: {error}"),
892        },
893    }
894}
895
896#[cfg(target_os = "macos")]
897fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
898    platform_install(unit)
899}
900#[cfg(all(unix, not(target_os = "macos")))]
901fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
902    let path = unit.path.display().to_string();
903    for args in [
904        vec!["--user", "link", path.as_str()],
905        vec!["--user", "enable", "--now", label],
906    ] {
907        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
908            action: "install",
909            detail: format!("systemctl: {error}"),
910        })?;
911        if !ok {
912            return Err(TeamsError::Service {
913                action: "install",
914                detail: format!("systemctl {}: {text}", args.join(" ")),
915            });
916        }
917    }
918    Ok(())
919}
920#[cfg(not(unix))]
921fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
922    // Task Scheduler reads task XML only as UTF-16; the unit itself stays UTF-8 so an install can compare it.
923    let task = unit.path.with_extension("utf16.xml");
924    let bytes: Vec<u8> = [0xFF, 0xFE]
925        .into_iter()
926        .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
927        .collect();
928    std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
929        path: task.clone(),
930        source,
931    })?;
932    let task_path = task.display().to_string();
933    let result = [
934        vec!["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
935        vec!["/Run", "/TN", label],
936    ]
937    .iter()
938    .try_for_each(|args| {
939        let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
940            action: "install",
941            detail: format!("schtasks: {error}"),
942        })?;
943        if ok {
944            Ok(())
945        } else {
946            Err(TeamsError::Service {
947                action: "install",
948                detail: format!("schtasks {}: {text}", args[0]),
949            })
950        }
951    });
952    let _ = std::fs::remove_file(&task);
953    result
954}
955
956#[cfg(target_os = "macos")]
957fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
958    let target = format!("{}/{label}", gui_domain());
959    let (ok, text) =
960        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
961            action: "uninstall",
962            detail: format!("launchctl: {error}"),
963        })?;
964    if !ok && !text.contains("No such process") && !text.contains("not find") {
965        return Err(TeamsError::Service {
966            action: "uninstall",
967            detail: format!("launchctl bootout {target}: {text}"),
968        });
969    }
970    // bootout returns before launchd has let the label go, and a bootstrap
971    // in that window fails with an I/O error; wait for it to be released.
972    for _ in 0..50 {
973        if !named_service_status(label).installed {
974            break;
975        }
976        std::thread::sleep(std::time::Duration::from_millis(100));
977    }
978    Ok(())
979}
980#[cfg(all(unix, not(target_os = "macos")))]
981fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
982    let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
983    Ok(())
984}
985#[cfg(not(unix))]
986fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
987    // The task goes first, so its minute trigger cannot start the connector again while it is being stopped.
988    let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
989        TeamsError::Service {
990            action: "uninstall",
991            detail: format!("schtasks: {error}"),
992        }
993    })?;
994    if !ok && named_service_status(label).installed {
995        return Err(TeamsError::Service {
996            action: "uninstall",
997            detail: format!("schtasks /Delete: {text}"),
998        });
999    }
1000    // Deleting a task leaves what it started running, so the connector is stopped by the one thing on its command
1001    // line that is its own: the environment file, on a node or its conhost. The path rides in the environment, not
1002    // the command line, so this query does not match itself.
1003    let env_path = connector_env_path(&teams_home(), label);
1004    let stopped = std::process::Command::new("powershell.exe")
1005        .args([
1006            "-NoProfile",
1007            "-NonInteractive",
1008            "-Command",
1009            "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 }",
1010        ])
1011        .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1012        .stdin(std::process::Stdio::null())
1013        .output();
1014    match stopped {
1015        Ok(output) if output.status.success() => Ok(()),
1016        Ok(output) => Err(TeamsError::Service {
1017            action: "uninstall",
1018            detail: format!(
1019                "the task is gone, but its connector may still run: {}",
1020                String::from_utf8_lossy(&output.stderr).trim()
1021            ),
1022        }),
1023        Err(error) => Err(TeamsError::Service {
1024            action: "uninstall",
1025            detail: format!(
1026                "the task is gone, but its connector may still run: powershell: {error}"
1027            ),
1028        }),
1029    }
1030}
1031
1032#[cfg(target_os = "macos")]
1033fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1034    let path = unit.path.display().to_string();
1035    let (ok, text) =
1036        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1037            TeamsError::Service {
1038                action: "install",
1039                detail: format!("launchctl: {error}"),
1040            }
1041        })?;
1042    if !ok {
1043        return Err(TeamsError::Service {
1044            action: "install",
1045            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1046        });
1047    }
1048    Ok(())
1049}
1050
1051/// Untested on this box (the receipt is macOS); these are the commands
1052/// `service_unit` prints as its `install_command`.
1053#[cfg(all(unix, not(target_os = "macos")))]
1054fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1055    let path = unit.path.display().to_string();
1056    for args in [
1057        vec!["--user", "link", path.as_str()],
1058        vec!["--user", "enable", "--now", SERVICE_NAME],
1059    ] {
1060        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1061            action: "install",
1062            detail: format!("systemctl: {error}"),
1063        })?;
1064        if !ok {
1065            return Err(TeamsError::Service {
1066                action: "install",
1067                detail: format!("systemctl {}: {text}", args.join(" ")),
1068            });
1069        }
1070    }
1071    Ok(())
1072}
1073
1074#[cfg(not(unix))]
1075fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
1076    Err(TeamsError::Service {
1077        action: "install",
1078        detail: "no service manager on this platform".into(),
1079    })
1080}
1081
1082/// Stop and unregister the unit, and remove the rendered file.
1083///
1084/// Idempotent: a unit the manager does not hold is not an error, because the
1085/// state the operator asked for is the state they get.
1086pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1087    platform_uninstall()?;
1088    let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1089    match std::fs::remove_file(&unit_path) {
1090        Ok(()) => {}
1091        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1092        Err(source) => {
1093            return Err(TeamsError::File {
1094                path: unit_path,
1095                source,
1096            })
1097        }
1098    }
1099    // `launchctl bootout` returns before the job is torn down, so the state
1100    // this reports is the settled one, not the manager mid-teardown.
1101    let mut state = service_status();
1102    for _ in 0..40 {
1103        if !state.installed {
1104            break;
1105        }
1106        std::thread::sleep(std::time::Duration::from_millis(100));
1107        state = service_status();
1108    }
1109    Ok(state)
1110}
1111
1112#[cfg(target_os = "macos")]
1113fn platform_uninstall() -> Result<(), TeamsError> {
1114    let target = format!("{}/{SERVICE_NAME}", gui_domain());
1115    let (ok, text) =
1116        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1117            action: "uninstall",
1118            detail: format!("launchctl: {error}"),
1119        })?;
1120    // `bootout` on a label nobody holds says so and exits non-zero.
1121    if !ok && !text.contains("No such process") && !text.contains("not find") {
1122        return Err(TeamsError::Service {
1123            action: "uninstall",
1124            detail: format!("launchctl bootout {target}: {text}"),
1125        });
1126    }
1127    Ok(())
1128}
1129
1130#[cfg(all(unix, not(target_os = "macos")))]
1131fn platform_uninstall() -> Result<(), TeamsError> {
1132    let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1133    Ok(())
1134}
1135
1136#[cfg(not(unix))]
1137fn platform_uninstall() -> Result<(), TeamsError> {
1138    Ok(())
1139}
1140
1141#[cfg(test)]
1142mod connector_service_tests {
1143    use super::*;
1144
1145    #[test]
1146    fn connector_unit_is_context_scoped_and_contains_no_credential() {
1147        let unit = connector_service_unit(
1148            Path::new("/tmp/teams home"),
1149            Path::new("/tmp/supercode home"),
1150            Path::new("/tmp/sdk/teams/bin/teams.mjs"),
1151            "/usr/bin/node",
1152            Path::new("/tmp/bin/supercode"),
1153            "work",
1154            Path::new("/tmp/project with spaces"),
1155            "srv_1",
1156            "team_1",
1157        )
1158        .unwrap();
1159        assert!(unit.text.contains("teams"));
1160        assert!(unit.text.contains("connect"));
1161        assert!(unit.text.contains("work"));
1162        assert!(!unit.text.contains("credential"));
1163        assert!(unit
1164            .path
1165            .file_name()
1166            .unwrap()
1167            .to_string_lossy()
1168            .contains(&connector_service_name("srv_1", "team_1", "work")));
1169    }
1170
1171    #[test]
1172    fn connector_labels_separate_context_and_team() {
1173        assert_ne!(
1174            connector_service_name("srv", "team-a", "work"),
1175            connector_service_name("srv", "team-b", "work")
1176        );
1177        assert_ne!(
1178            connector_service_name("srv", "team-a", "work"),
1179            connector_service_name("srv", "team-a", "personal")
1180        );
1181    }
1182
1183    #[test]
1184    fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
1185        let unsafe_unit = connector_service_unit(
1186            Path::new("/tmp/teams"),
1187            Path::new("/tmp/home"),
1188            Path::new("/tmp/entry"),
1189            "/usr/bin/node",
1190            Path::new("/tmp/supercode"),
1191            "bad\ncontext",
1192            Path::new("/tmp/work"),
1193            "srv",
1194            "team",
1195        );
1196        assert!(unsafe_unit.is_err());
1197        let render = |entry: &str, cwd: &str| {
1198            connector_service_unit(
1199                Path::new("/tmp/teams & logs"),
1200                Path::new("/tmp/home $x"),
1201                Path::new(entry),
1202                "/usr/bin/node",
1203                Path::new("/tmp/super\"code"),
1204                "work",
1205                Path::new(cwd),
1206                "srv",
1207                "team",
1208            )
1209        };
1210        if cfg!(windows) {
1211            // Task Scheduler would expand `%i`; such a path is refused rather than run as something else.
1212            assert!(render("/tmp/entry %i", "/tmp/a & b $").is_err());
1213            let unit = render("/tmp/entry", "/tmp/a & b $").unwrap();
1214            assert!(unit.text.contains("--cwd &quot;/tmp/a &amp; b $&quot;"));
1215            let (env_path, env_text) = &unit.files[0];
1216            assert!(env_path.ends_with(format!(
1217                "{}.env",
1218                connector_service_name("srv", "team", "work")
1219            )));
1220            assert!(env_text.contains("SUPERCODE_BIN='/tmp/super\"code'\n"));
1221            assert!(!unit.text.contains("super\"code"));
1222        } else if cfg!(target_os = "macos") {
1223            let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1224            assert!(unit.text.contains("&amp;"));
1225        } else {
1226            let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1227            assert!(unit.text.contains("%%"));
1228            assert!(unit.text.contains("$$"));
1229            assert!(unit.text.contains("\\\""));
1230        }
1231    }
1232
1233    #[test]
1234    fn windows_arguments_split_back_out_whole() {
1235        assert_eq!(windows_arg("plain"), "plain");
1236        assert_eq!(windows_arg(""), "\"\"");
1237        assert_eq!(
1238            windows_arg(r"C:\Program Files\nodejs\node.exe"),
1239            r#""C:\Program Files\nodejs\node.exe""#
1240        );
1241        assert_eq!(windows_arg(r#"say "hi""#), r#""say \"hi\"""#);
1242        // Backslashes before a quote, and before the closing quote, are doubled; elsewhere they are literal.
1243        assert_eq!(windows_arg(r#"a\"b"#), r#""a\\\"b""#);
1244        assert_eq!(windows_arg(r"C:\my dir\"), r#""C:\my dir\\""#);
1245    }
1246
1247    #[test]
1248    fn env_file_values_are_quoted_literally() {
1249        assert_eq!(env_file_value(r"C:\new\tab").unwrap(), r"'C:\new\tab'");
1250        assert_eq!(env_file_value("O'Brien").unwrap(), "`O'Brien`");
1251        assert_eq!(env_file_value("it's `x`").unwrap(), "\"it's `x`\"");
1252        assert!(env_file_value(r"it's `x` \n").is_err());
1253    }
1254}