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