From 1833915d4c978a7344bace8165762e2e8cf5196c Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 29 Jun 2026 11:00:28 +0300 Subject: [PATCH 1/4] Linux support --- .github/workflows/release.yml | 62 +++++ Cargo.lock | 32 +-- crates/edison-stdiod/src/platform/linux.rs | 274 +++++++++++++++++++++ crates/edison-stdiod/src/platform/mod.rs | 23 +- scripts/build-linux.sh | 51 ++++ 5 files changed, 411 insertions(+), 31 deletions(-) create mode 100644 crates/edison-stdiod/src/platform/linux.rs create mode 100755 scripts/build-linux.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51d6c60..99cb159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,3 +92,65 @@ jobs: dist/SHA256SUMS fail_on_unmatched_files: true generate_release_notes: true + + linux: + name: Linux static musl (x64 + arm64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Both musl targets; built fully STATIC so the published binary has zero + # glibc/libc dependency and runs on any distro (incl. Alpine / containers). + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + # zig + cargo-zigbuild supply a C cross-toolchain for `ring`'s C-crypto, + # which can't be cross-built to musl with the stock toolchain. Mirrors the + # desktop repo's scripts/build-stdiod-linux.sh. + - uses: mlugg/setup-zig@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - name: Build static musl (both arches) + run: | + set -euo pipefail + mkdir -p dist + for spec in "x64:x86_64-unknown-linux-musl" "arm64:aarch64-unknown-linux-musl"; do + arch="${spec%%:*}"; target="${spec##*:}" + echo "Building $target ..." + cargo zigbuild --release --bin "$BIN_NAME" --target "$target" + cp "target/$target/release/$BIN_NAME" "dist/edison-stdiod-linux-$arch" + chmod +x "dist/edison-stdiod-linux-$arch" + done + file dist/edison-stdiod-linux-* + # x64 binary is native to this runner - smoke-test it. + ./dist/edison-stdiod-linux-x64 --version || true + + - name: Generate checksums + working-directory: dist + run: shasum -a 256 edison-stdiod-linux-x64 edison-stdiod-linux-arm64 > SHA256SUMS-linux + + - name: Upload workflow artifact + uses: actions/upload-artifact@v5 + with: + name: edison-stdiod-linux + path: | + dist/edison-stdiod-linux-x64 + dist/edison-stdiod-linux-arm64 + dist/SHA256SUMS-linux + if-no-files-found: error + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v3 + with: + files: | + dist/edison-stdiod-linux-x64 + dist/edison-stdiod-linux-arm64 + dist/SHA256SUMS-linux + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock index 364c3c0..616cfe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,7 +287,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-tungstenite", "toml", @@ -880,7 +880,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -901,7 +901,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -982,7 +982,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1284,33 +1284,13 @@ dependencies = [ "syn", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1578,7 +1558,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror", ] [[package]] diff --git a/crates/edison-stdiod/src/platform/linux.rs b/crates/edison-stdiod/src/platform/linux.rs new file mode 100644 index 0000000..b859606 --- /dev/null +++ b/crates/edison-stdiod/src/platform/linux.rs @@ -0,0 +1,274 @@ +//! Linux systemd **user** service integration. +//! +//! Writes a unit to `~/.config/systemd/user/edison-stdiod.service` and +//! manages it with `systemctl --user` (no `sudo`, no system-level unit) so +//! the daemon runs as the logged-in user with their HOME, PATH, and +//! secrets - the exact analog of the macOS LaunchAgent. +//! +//! The unit sets: +//! +//! - `Restart=on-failure` - systemd restarts the binary on crash +//! (the daemon also has its own reconnect loop, so only a full process +//! exit needs this). +//! - `WantedBy=default.target` - start on login. We deliberately do **not** +//! call `loginctl enable-linger`, so the daemon runs only while the user +//! has a session - matching the macOS LaunchAgent and the Windows +//! "run only when logged on" task. +//! +//! ## systemd is not guaranteed +//! +//! Most distros use systemd, but Alpine (OpenRC - and our prime static-musl +//! target), Void (runit), Artix/Devuan (OpenRC/runit/sysvinit) and bare +//! containers do not. We therefore **detect** a reachable systemd user +//! instance and, when absent, return an actionable error rather than writing +//! a unit that never starts. The daemon itself still runs fine via +//! `edison-stdiod run` under any init system or process manager. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use tracing::{debug, info, warn}; + +use crate::paths; + +const UNIT_NAME: &str = "edison-stdiod.service"; + +/// `~/.config/systemd/user/edison-stdiod.service`. +fn unit_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("HOME not set"))?; + let dir = home.join(".config/systemd/user"); + std::fs::create_dir_all(&dir) + .with_context(|| format!("creating {}", dir.display()))?; + Ok(dir.join(UNIT_NAME)) +} + +/// Run `systemctl --user `. +fn systemctl(args: &[&str]) -> Result { + debug!(?args, "systemctl --user"); + let out = Command::new("systemctl") + .arg("--user") + .args(args) + .output() + .context("failed to invoke systemctl (is it installed?)")?; + Ok(out) +} + +/// True iff a systemd **user** instance is reachable for this session. +/// +/// Checks two things: that `systemctl` is invokable at all, and that a user +/// bus answers. When there is no user manager (OpenRC/runit distros, many +/// containers) `systemctl --user` fails to connect to the bus - we detect +/// that distinct failure so callers can degrade gracefully. +pub fn systemd_user_available() -> bool { + // `is-system-running` is read-only and answers whenever the bus is up + // (it may report "degraded"/"starting" with a non-zero exit, which is + // still "available" - so we gate on the bus-connection error text, not + // the exit code). + match systemctl(&["is-system-running"]) { + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + !stderr.contains("Failed to connect to bus") + } + // systemctl missing / not executable -> not a systemd system. + Err(_) => false, + } +} + +fn render_unit(binary: &Path, log: &Path) -> String { + // systemd `%h` expands to the user's home at load time. We set an + // explicit PATH for the same reason macOS does: a user-service inherits + // a minimal environment, which breaks child MCP spawns (npx/uvx/etc.) + // that live in ~/.local/bin or /usr/local/bin. The daemon also augments + // child PATH at spawn, but seeding it here keeps the common case working + // out of the box. + // + // StandardOutput/StandardError=append: route the daemon's stderr into the + // same `daemon.log` file `edison-stdiod logs` tails (the daemon logs to + // stderr on Linux, and under a user service that would otherwise only land + // in journald). Mirrors the macOS plist's StandardErrorPath. `append:` + // needs systemd v240+ (2018); its parent dir is created at install time. + let bin = binary.display(); + let log = log.display(); + format!( + "[Unit]\n\ + Description=Edison Watch stdio tunnel daemon\n\ + Documentation=https://edison.watch\n\ + After=network-online.target\n\ + Wants=network-online.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart={bin} run\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + Environment=PATH=%h/.local/bin:%h/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin\n\ + StandardOutput=append:{log}\n\ + StandardError=append:{log}\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + ) +} + +fn write_unit(path: &Path, body: &str) -> Result<()> { + let tmp = path.with_extension("service.tmp"); + std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("renaming -> {}", path.display()))?; + Ok(()) +} + +/// Write the unit + `systemctl --user enable --now`. Idempotent. +pub fn install() -> Result<()> { + // Refuse to install without credentials - the daemon would just spin in + // a reconnect loop. Surface it now, not later in the logs. (Mirrors the + // macOS path.) + let cfg = crate::config::PersistedConfig::load()?; + if cfg.api_key.as_deref().unwrap_or("").is_empty() + || cfg.backend_url.as_deref().unwrap_or("").is_empty() + { + return Err(anyhow!( + "no credentials on disk. Run `edison-stdiod login --backend ... --api-key ...` first.", + )); + } + + if !systemd_user_available() { + return Err(anyhow!( + "no systemd user instance is available on this system.\n\ + edison-stdiod still runs fine without it - start the daemon under\n\ + your init system or process manager instead, e.g.:\n\ + \n edison-stdiod run\n\n\ + (OpenRC/runit/s6 service files and containers should exec that\n\ + command directly.)", + )); + } + + let binary = std::env::current_exe().context("could not resolve current exe path")?; + let log = paths::daemon_log_file()?; + // `StandardError=append:` writes to the file but does not create its + // parent directory - do that here so the first start doesn't fail. + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating log dir {}", parent.display()))?; + } + let unit = unit_path()?; + let body = render_unit(&binary, &log); + write_unit(&unit, &body)?; + info!(path = %unit.display(), "wrote systemd user unit"); + + // daemon-reload so a re-install picks up a moved binary or edited unit. + let reload = systemctl(&["daemon-reload"])?; + if !reload.status.success() { + warn!( + stderr = %String::from_utf8_lossy(&reload.stderr), + "systemctl --user daemon-reload reported an error; continuing" + ); + } + + // enable = start on login; --now = start immediately. + let out = systemctl(&["enable", "--now", UNIT_NAME])?; + if !out.status.success() { + return Err(anyhow!( + "systemctl --user enable --now {UNIT_NAME} failed: {}", + String::from_utf8_lossy(&out.stderr).trim(), + )); + } + info!(unit = UNIT_NAME, "systemd user service enabled and started"); + println!("Installed systemd user service: {}", unit.display()); + println!("Daemon is running. Tail logs with `edison-stdiod logs --follow`."); + Ok(()) +} + +/// `systemctl --user disable --now` + remove the unit. Idempotent. +pub fn uninstall() -> Result<()> { + // disable --now even if the unit file is already gone; ignore failures + // (e.g. "not loaded") so the call is safe to repeat. Only attempt it + // when a user bus exists - otherwise there is nothing to talk to. + if systemd_user_available() { + let out = systemctl(&["disable", "--now", UNIT_NAME])?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + debug!(stderr = %stderr, "systemctl --user disable reported an error; continuing"); + } + } + + let unit = unit_path()?; + match std::fs::remove_file(&unit) { + Ok(()) => info!(path = %unit.display(), "removed systemd user unit"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + info!("no systemd user unit to remove"); + } + Err(e) => return Err(e).with_context(|| format!("removing {}", unit.display())), + } + + if systemd_user_available() { + let _ = systemctl(&["daemon-reload"]); + } + println!("Uninstalled systemd user service. Config + logs left in place (--purge to wipe)."); + Ok(()) +} + +/// True iff the unit file exists on disk - the canonical "did install run" +/// signal (cheap, no bus round-trip). +#[allow(dead_code)] // consumed by the `status` subcommand +pub fn is_installed() -> Result { + let home = match dirs::home_dir() { + Some(h) => h, + None => return Ok(false), + }; + Ok(home.join(".config/systemd/user").join(UNIT_NAME).exists()) +} + +/// True iff `systemctl --user is-active` reports the unit active. +#[allow(dead_code)] // consumed by the `status` subcommand +pub fn is_running() -> Result { + if !systemd_user_available() { + return Ok(false); + } + // `is-active` prints "active" / "inactive" / "failed" to stdout and sets + // the exit code accordingly. The text is locale-independent (an enum + // serialisation), unlike the launchctl/schtasks parses on other OSes. + let out = systemctl(&["is-active", UNIT_NAME])?; + let state = String::from_utf8_lossy(&out.stdout); + Ok(state.trim() == "active") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_unit_includes_execstart_and_install() { + let body = render_unit( + Path::new("/usr/local/bin/edison-stdiod"), + Path::new("/home/me/.local/state/edison-stdiod/daemon.log"), + ); + assert!(body.contains("ExecStart=/usr/local/bin/edison-stdiod run")); + assert!(body.contains("Restart=on-failure")); + assert!(body.contains("WantedBy=default.target")); + assert!(body.contains("[Service]")); + } + + #[test] + fn render_unit_seeds_path_for_child_spawns() { + let body = render_unit(Path::new("/bin/x"), Path::new("/tmp/x.log")); + assert!(body.contains("Environment=PATH=")); + assert!(body.contains("%h/.local/bin")); + assert!(body.contains("/usr/local/bin")); + } + + #[test] + fn render_unit_routes_logs_to_daemon_log() { + let body = render_unit(Path::new("/bin/x"), Path::new("/tmp/x.log")); + assert!(body.contains("StandardOutput=append:/tmp/x.log")); + assert!(body.contains("StandardError=append:/tmp/x.log")); + } + + #[test] + fn render_unit_has_no_linger_directive() { + // We intentionally rely on the default (run-while-logged-in) + // semantics; lingering would change that and must not creep in. + let body = render_unit(Path::new("/bin/x"), Path::new("/tmp/x.log")); + assert!(!body.to_lowercase().contains("linger")); + } +} diff --git a/crates/edison-stdiod/src/platform/mod.rs b/crates/edison-stdiod/src/platform/mod.rs index e049fe3..998e6f3 100644 --- a/crates/edison-stdiod/src/platform/mod.rs +++ b/crates/edison-stdiod/src/platform/mod.rs @@ -35,19 +35,32 @@ pub use windows::{install, uninstall}; #[allow(unused_imports)] pub use windows::{is_installed, is_running}; -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(target_os = "linux")] +pub use linux::{install, uninstall}; + +#[cfg(target_os = "linux")] +#[allow(unused_imports)] +pub use linux::{is_installed, is_running}; + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] mod stub { use anyhow::{anyhow, Result}; pub fn install() -> Result<()> { Err(anyhow!( - "install is only implemented for macOS in v1. \ - See stdiod/ARCHITECTURE.md for the Linux / Windows roadmap.", + "install is implemented for macOS, Linux (systemd), and Windows. \ + This OS has no supervisor integration - run `edison-stdiod run` \ + directly. See stdiod/REQUIREMENTS.md.", )) } pub fn uninstall() -> Result<()> { - Err(anyhow!("uninstall is only implemented for macOS in v1.",)) + Err(anyhow!( + "uninstall is implemented for macOS, Linux (systemd), and Windows.", + )) } pub fn is_installed() -> Result { @@ -59,5 +72,5 @@ mod stub { } } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] pub use stub::{install, is_installed, is_running, uninstall}; diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 0000000..0134770 --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Build the edison-stdiod daemon as a fully STATIC Linux binary (x64 + arm64) +# and stage it under dist/. Static musl means zero glibc dependency - the same +# file runs on any distro (Debian, Fedora, Arch, Alpine, containers). +# +# Why cargo-zigbuild: rustls pulls in `ring`, whose C-crypto can't be +# cross-compiled to musl with the stock toolchain. zig (via cargo-zigbuild) +# supplies the C cross-toolchain. Works from macOS or Linux hosts. (On a Linux +# host you can also just `cargo build --release` for the host arch, with +# `musl-tools` installed, if you only need one arch natively.) +# +# Usage: bash scripts/build-linux.sh # both arches +# TARGET_ARCHES="x64" bash scripts/build-linux.sh # one arch +# +# Prereqs: brew install zig (or see ziglang.org) +# cargo install cargo-zigbuild +# rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUT_DIR="$REPO_ROOT/dist" +BIN_NAME="edison-stdiod" + +command -v zig >/dev/null 2>&1 || { + echo "build-linux.sh: zig required (brew install zig / see ziglang.org)" >&2; exit 1; } +command -v cargo-zigbuild >/dev/null 2>&1 || { + echo "build-linux.sh: cargo-zigbuild required (cargo install cargo-zigbuild)" >&2; exit 1; } + +mkdir -p "$OUT_DIR" +WANT="${TARGET_ARCHES:-x64 arm64}" + +for spec in "x64:x86_64-unknown-linux-musl" "arm64:aarch64-unknown-linux-musl"; do + arch="${spec%%:*}" + target="${spec##*:}" + case " $WANT " in *" $arch "*) ;; *) continue ;; esac + + if ! rustup target list --installed | grep -q "^${target}\$"; then + echo "Installing rustup target $target ..." + rustup target add "$target" + fi + echo "Building $BIN_NAME for $target ..." + ( cd "$REPO_ROOT" && cargo zigbuild --release --bin "$BIN_NAME" --target "$target" ) + cp "$REPO_ROOT/target/$target/release/$BIN_NAME" "$OUT_DIR/edison-stdiod-linux-$arch" + chmod +x "$OUT_DIR/edison-stdiod-linux-$arch" + echo "Staged -> $OUT_DIR/edison-stdiod-linux-$arch" +done + +echo "Done. Static Linux binaries under $OUT_DIR/" +file "$OUT_DIR"/edison-stdiod-linux-* 2>/dev/null || true From f2216caa370d89d821dab6ab9dc2a3d813fba9eb Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 29 Jun 2026 12:01:55 +0300 Subject: [PATCH 2/4] fix format --- crates/edison-stdiod/src/platform/linux.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/edison-stdiod/src/platform/linux.rs b/crates/edison-stdiod/src/platform/linux.rs index b859606..6f059f8 100644 --- a/crates/edison-stdiod/src/platform/linux.rs +++ b/crates/edison-stdiod/src/platform/linux.rs @@ -38,8 +38,7 @@ const UNIT_NAME: &str = "edison-stdiod.service"; fn unit_path() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow!("HOME not set"))?; let dir = home.join(".config/systemd/user"); - std::fs::create_dir_all(&dir) - .with_context(|| format!("creating {}", dir.display()))?; + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; Ok(dir.join(UNIT_NAME)) } From 26c38413264b11b52106fe4f77396a76134241e6 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Thu, 2 Jul 2026 23:47:34 +0300 Subject: [PATCH 3/4] Resolve PATH on linux when starting server subprocesses --- crates/edison-stdiod/src/daemon.rs | 6 ++ crates/edison-stdiod/src/proc.rs | 119 ++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/crates/edison-stdiod/src/daemon.rs b/crates/edison-stdiod/src/daemon.rs index 44705bf..0e81a04 100644 --- a/crates/edison-stdiod/src/daemon.rs +++ b/crates/edison-stdiod/src/daemon.rs @@ -109,6 +109,12 @@ const BACKOFF_MAX: Duration = Duration::from_secs(30); pub async fn run(args: RunArgs) -> Result<()> { let resolved = ResolvedRun::from_args(args)?; + + // Resolve the interactive login-shell PATH once, up front, so child MCP + // servers can find version-manager node/npx (nvm/fnm/...) - the daemon's own + // (systemd/launchd) PATH doesn't include shell-rc additions. See proc.rs. + crate::proc::init_child_env().await; + // The supervisor - and the broker handle the children depend on - // live across reconnects. ``apply_snapshot`` on each new WS will // diff and reconcile. diff --git a/crates/edison-stdiod/src/proc.rs b/crates/edison-stdiod/src/proc.rs index d751831..fe75370 100644 --- a/crates/edison-stdiod/src/proc.rs +++ b/crates/edison-stdiod/src/proc.rs @@ -36,8 +36,25 @@ use crate::tunnel::OutgoingHandle; /// CreateProcess can't launch them directly and `Command::new` doesn't apply /// PATHEXT. fn build_child_command(program: &str, args: &[String]) -> Command { - #[cfg(not(windows))] + #[cfg(target_os = "linux")] { + // Resolve against the augmented PATH (interactive login-shell PATH ∪ the + // daemon PATH) so version-manager node/npx (nvm/fnm/volta) is found, and + // set it on the child so a resolved `npx` can in turn find `node`. Linux + // only: the systemd `--user` unit's PATH omits shell-rc additions. + let path = linux::augmented_path(); + let mut cmd = match linux::resolve_program(program, &path) { + Some(abs) => Command::new(abs), + None => Command::new(program), + }; + cmd.args(args); + cmd.env("PATH", &path); + cmd + } + #[cfg(all(unix, not(target_os = "linux")))] + { + // macOS / other Unix: inherit the daemon PATH as-is (the macOS + // LaunchAgent plist already sets it wide enough for npx/uvx). let mut cmd = Command::new(program); cmd.args(args); cmd @@ -88,6 +105,106 @@ fn build_child_command(program: &str, args: &[String]) -> Command { } } +/// One-time child-spawn environment setup, run once at daemon startup. +/// +/// On Linux, resolves the user's *interactive login-shell* PATH so child MCP +/// servers can find node/npx/uvx installed via a version manager (nvm/fnm/volta/ +/// asdf) - whose PATH additions live in shell rc files that the systemd `--user` +/// service never sources. No-op on macOS (the LaunchAgent plist already sets a +/// wide PATH) and Windows (bundled-runtimes PATH augmentation in `win`). +pub(crate) async fn init_child_env() { + #[cfg(target_os = "linux")] + linux::init_augmented_path().await; +} + +#[cfg(target_os = "linux")] +mod linux { + use std::collections::HashSet; + use std::ffi::{OsStr, OsString}; + use std::path::{Path, PathBuf}; + use std::sync::OnceLock; + use std::time::Duration; + + // Login-shell PATH ∪ daemon PATH, resolved once by init_augmented_path(). + static AUGMENTED_PATH: OnceLock = OnceLock::new(); + + /// Resolve + cache the augmented PATH. Idempotent; best-effort (on failure + /// or timeout the cache stays empty and augmented_path() uses the daemon PATH). + pub async fn init_augmented_path() { + if AUGMENTED_PATH.get().is_some() { + return; + } + let merged = merge_paths(login_shell_path().await, std::env::var_os("PATH")); + let _ = AUGMENTED_PATH.set(merged); + } + + /// PATH to use for child spawns: the cached login-shell∪daemon PATH, or the + /// daemon PATH if init hasn't run / the shell probe failed. + pub fn augmented_path() -> OsString { + AUGMENTED_PATH + .get() + .cloned() + .unwrap_or_else(|| std::env::var_os("PATH").unwrap_or_default()) + } + + /// Capture `$PATH` as the user's interactive login shell sees it (so + /// nvm/fnm/volta setup in rc files is applied). Markers isolate the value + /// from any banner an rc file prints to stdout; stdin is null so an rc that + /// reads input can't hang; the probe is bounded by a timeout. + async fn login_shell_path() -> Option { + const START: &str = "__EW_PATH_START__"; + const END: &str = "__EW_PATH_END__"; + let shell = std::env::var_os("SHELL").unwrap_or_else(|| OsString::from("/bin/bash")); + let mut cmd = tokio::process::Command::new(&shell); + cmd.arg("-lic") + .arg(format!("printf '{START}%s{END}' \"$PATH\"")) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let out = tokio::time::timeout(Duration::from_secs(5), cmd.output()) + .await + .ok()? // timed out + .ok()?; // spawn/exec failed + let stdout = String::from_utf8_lossy(&out.stdout); + let path = stdout.split_once(START)?.1.split_once(END)?.0; + (!path.is_empty()).then(|| OsString::from(path)) + } + + /// Merge two PATH values: entries from `primary` first, then any from + /// `secondary` not already present. Order-preserving, de-duplicated. + fn merge_paths(primary: Option, secondary: Option) -> OsString { + let mut seen: HashSet = HashSet::new(); + let mut dirs: Vec = Vec::new(); + for src in [primary, secondary].into_iter().flatten() { + for dir in std::env::split_paths(&src) { + if seen.insert(dir.clone()) { + dirs.push(dir); + } + } + } + std::env::join_paths(dirs).unwrap_or_default() + } + + /// Resolve `program` to an absolute path against `path`. A name that already + /// contains '/' is returned as-is; a bare name is searched on each PATH dir. + /// None when not found (the caller keeps the bare name, so spawn still errors + /// with a clear "not found" rather than silently doing nothing). + pub fn resolve_program(program: &str, path: &OsStr) -> Option { + if program.contains('/') { + return Some(PathBuf::from(program)); + } + std::env::split_paths(path) + .map(|dir| dir.join(program)) + .find(|cand| is_executable(cand)) + } + + fn is_executable(p: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } +} + #[cfg(windows)] mod win { use std::ffi::{OsStr, OsString}; From 88fcd6028f9eab337c81ca1aa4d45f0e3ccba800 Mon Sep 17 00:00:00 2001 From: Ilia Manolov Date: Fri, 3 Jul 2026 09:58:57 +0100 Subject: [PATCH 4/4] Quote ExecStart binary path in systemd unit template Paths containing spaces would otherwise break service startup. Identified by cubic in PR review. Co-Authored-By: Claude Fable 5 --- crates/edison-stdiod/src/platform/linux.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/edison-stdiod/src/platform/linux.rs b/crates/edison-stdiod/src/platform/linux.rs index 6f059f8..e384bdb 100644 --- a/crates/edison-stdiod/src/platform/linux.rs +++ b/crates/edison-stdiod/src/platform/linux.rs @@ -98,7 +98,7 @@ fn render_unit(binary: &Path, log: &Path) -> String { \n\ [Service]\n\ Type=simple\n\ - ExecStart={bin} run\n\ + ExecStart=\"{bin}\" run\n\ Restart=on-failure\n\ RestartSec=5\n\ Environment=PATH=%h/.local/bin:%h/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin\n\ @@ -242,7 +242,7 @@ mod tests { Path::new("/usr/local/bin/edison-stdiod"), Path::new("/home/me/.local/state/edison-stdiod/daemon.log"), ); - assert!(body.contains("ExecStart=/usr/local/bin/edison-stdiod run")); + assert!(body.contains("ExecStart=\"/usr/local/bin/edison-stdiod\" run")); assert!(body.contains("Restart=on-failure")); assert!(body.contains("WantedBy=default.target")); assert!(body.contains("[Service]"));