Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/mcp_detector_daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
notify = "8.2.0"
notify-debouncer-full = "0.7.0"

# libc is only used by the Unix privilege/user code (getpwuid, setuid, chown,
# kill); cfg-gated out on Windows, which has no such model.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
51 changes: 39 additions & 12 deletions crates/mcp_detector_daemon/src/claude_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::process::Command;

use anyhow::Context;

#[cfg(unix)]
use crate::{paths, platform};

/// `claude mcp add --transport http --scope user [--header …] edison-watch <url>`.
Expand Down Expand Up @@ -57,9 +58,29 @@ pub fn remove(user: &str) -> anyhow::Result<()> {
fn run_as(user: &str, args: &[String]) -> anyhow::Result<()> {
let mut cmd = Command::new("claude");
cmd.args(args);

// The daemon is GUI-subsystem on Windows; suppress the console window a
// console child (`claude`) would otherwise flash.
no_window(&mut cmd);
// When running as root, drop to the target user so the CLI writes into their
// home. In the user-mode dev build this branch is skipped.
// home. No-op in the user-mode dev build and on non-Unix (no root/setuid).
drop_to_user(&mut cmd, user);

let output = cmd.output().context("spawning `claude` CLI")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!(
"`claude {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
)
}
}

/// Under root, set HOME/USER for the target user and `setuid`/`setgid` to them
/// in the child before exec, so the `claude` CLI writes into their home.
#[cfg(unix)]
fn drop_to_user(cmd: &mut Command, user: &str) {
if paths::is_root()
&& let Some((uid, gid)) = platform::uid_gid_for(user)
{
Expand All @@ -82,15 +103,21 @@ fn run_as(user: &str, args: &[String]) -> anyhow::Result<()> {
});
}
}
}

let output = cmd.output().context("spawning `claude` CLI")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!(
"`claude {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
)
}
/// Non-Unix: the daemon already runs as the single logged-in user, so there is
/// nothing to drop to.
#[cfg(not(unix))]
fn drop_to_user(_cmd: &mut Command, _user: &str) {}

/// Windows: spawn the child with no console window (CREATE_NO_WINDOW), matching
/// the schtasks/whoami spawns and stdiod's behaviour.
#[cfg(windows)]
fn no_window(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}

#[cfg(not(windows))]
fn no_window(_cmd: &mut Command) {}
9 changes: 9 additions & 0 deletions crates/mcp_detector_daemon/src/hook_consumer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,19 @@ fn sweep_orphaned(edison_dir: &Path) {

/// `kill(pid, 0)`: 0 → alive; `EPERM` → alive (exists, no permission); `ESRCH`
/// → gone.
#[cfg(unix)]
fn process_alive(pid: i32) -> bool {
// SAFETY: kill with signal 0 performs only an existence/permission check.
if unsafe { libc::kill(pid, 0) } == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

/// Non-Unix has no `kill(pid, 0)` probe; a real liveness check lands with the
/// Windows hook-consumer work. Conservatively assume alive so we never delete a
/// live process's pending/error entry.
#[cfg(not(unix))]
fn process_alive(_pid: i32) -> bool {
true
}
113 changes: 89 additions & 24 deletions crates/mcp_detector_daemon/src/ipc.rs
Original file line number Diff line number Diff line change
@@ -1,55 +1,116 @@
//! The Unix-socket IPC server.
//! The IPC control server: a Unix-domain socket on Unix, a named pipe on
//! Windows.
//!
//! On Unix each connection's OS user is taken from the socket's **peer
//! credentials** (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not
//! from anything the client sends, so every request is scoped to the
//! kernel-reported uid. On Windows the daemon runs per-user (a logon Scheduled
//! Task), so the peer is always the daemon's own user.
//!
//! Each connection's OS user is taken from the socket's **peer credentials**
//! (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not from anything
//! the client sends — so every request is scoped to the kernel-reported uid.
//! Requests/replies are newline-delimited JSON; the daemon also pushes
//! [`Event`](crate::protocol::Event)s to a connection when they match its peer user.
//! [`Event`](crate::protocol::Event)s to a connection when they match its user.

use std::path::{Path, PathBuf};

use serde::Serialize;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream, unix::OwnedWriteHalf};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::broadcast;

use crate::ops;
use crate::platform;
use crate::protocol::{Reply, Request};
use crate::runner::EventTx;

/// Default socket path (dev). The root build will use `/var/run/...`.
// ── socket / pipe address ──────────────────────────────────────────────────

/// Default IPC address. A socket file under base_dir on Unix; a per-user named
/// pipe on Windows (pipes are machine-global, so namespace by user).
#[cfg(not(windows))]
pub fn default_socket_path() -> PathBuf {
crate::paths::base_dir().join("daemon.sock")
}

/// Serve the IPC socket at `path` until the process is stopped. `events` feeds
#[cfg(windows)]
pub fn default_socket_path() -> PathBuf {
PathBuf::from(format!(
r"\\.\pipe\edison-detectord.{}",
crate::paths::current_username()
))
}

// ── serve loop (transport-specific) ─────────────────────────────────────────

/// Serve the IPC endpoint at `path` until the process is stopped. `events` feeds
/// per-user pushes to connected clients.
#[cfg(unix)]
pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> {
use tokio::net::UnixListener;

crate::paths::ensure_base_dir()?;
let _ = std::fs::remove_file(path); // a stale socket file blocks bind
let listener = UnixListener::bind(path)?;
tracing::info!(socket = %path.display(), "IPC listening");

loop {
let (stream, _addr) = listener.accept().await?;
let events = events.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, events).await {
tracing::debug!(error = %e, "connection ended");
}
});
// Identity comes from the kernel peer creds, not the client.
let Some(user) = stream
.peer_cred()
.ok()
.and_then(|c| crate::platform::username_for_uid(c.uid()))
else {
tracing::warn!("could not resolve connection's peer user; dropping it");
continue;
};
spawn_conn(stream, user, events.clone());
}
}

#[cfg(windows)]
pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> {
use tokio::net::windows::named_pipe::ServerOptions;

crate::paths::ensure_base_dir()?;
let name = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("pipe name is not valid UTF-8: {}", path.display()))?;
tracing::info!(pipe = %name, "IPC listening");

// Always keep one server instance listening: on each connect, hand off the
// connected instance and immediately create the next. `first_pipe_instance`
// on the very first server refuses to bind if another process already owns
// the name (anti-squat).
let mut server = ServerOptions::new()
.first_pipe_instance(true)
.create(name)?;
loop {
server.connect().await?;
let connected = server;
server = ServerOptions::new().create(name)?;
// Per-user daemon: the peer is this daemon's own user.
spawn_conn(connected, crate::paths::current_username(), events.clone());
}
}

async fn handle_conn(stream: UnixStream, events: EventTx) -> anyhow::Result<()> {
// Identity comes from the kernel, not the client.
let uid = stream.peer_cred()?.uid();
let user =
platform::username_for_uid(uid).ok_or_else(|| anyhow::anyhow!("unknown uid {uid}"))?;
tracing::debug!(uid, %user, "connection");
// ── connection handling (transport-agnostic) ────────────────────────────────

fn spawn_conn<S>(stream: S, user: String, events: EventTx)
where
S: AsyncRead + AsyncWrite + Send + 'static,
{
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, user, events).await {
tracing::debug!(error = %e, "connection ended");
}
});
}

async fn handle_conn<S>(stream: S, user: String, events: EventTx) -> anyhow::Result<()>
where
S: AsyncRead + AsyncWrite + Send + 'static,
{
tracing::debug!(%user, "connection");

let (rd, mut wr) = stream.into_split();
let (rd, mut wr) = tokio::io::split(stream);
let mut lines = BufReader::new(rd).lines();
let mut rx = events.subscribe();

Expand Down Expand Up @@ -81,7 +142,11 @@ async fn handle_conn(stream: UnixStream, events: EventTx) -> anyhow::Result<()>
Ok(())
}

async fn write_json<T: Serialize>(wr: &mut OwnedWriteHalf, val: &T) -> anyhow::Result<()> {
async fn write_json<W, T>(wr: &mut W, val: &T) -> anyhow::Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let mut buf = serde_json::to_vec(val)?;
buf.push(b'\n');
wr.write_all(&buf).await?;
Expand Down
15 changes: 10 additions & 5 deletions crates/mcp_detector_daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
//! Two interfaces: an operator CLI (below) and an IPC socket (`serve`) — both
//! go through the shared, per-user [`ops`] layer.

// Release Windows builds are GUI-subsystem so the supervisor (Scheduled Task)
// and one-shot CLI spawns don't flash a console window. Debug keeps the console
// so `run`/`daemon` still print to a terminal during dev.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::collections::HashMap;
use std::path::PathBuf;
use std::process::ExitCode;
Expand All @@ -17,7 +22,6 @@ mod claude_cli;
mod enrollment;
mod hook_consumer;
mod ipc;
mod launchd;
mod logging;
mod ops;
mod paths;
Expand All @@ -26,6 +30,7 @@ mod protocol;
mod quarantined;
mod recovery;
mod runner;
mod service;
mod status;
mod supervisor;

Expand Down Expand Up @@ -217,13 +222,13 @@ async fn dispatch(cmd: Cmd) -> anyhow::Result<()> {
SecretCmd::Reset { key, confirm } => cmd_reset_secret(key, confirm).await,
},
Cmd::Service { action } => match action {
ServiceCmd::Install { enforce } => launchd::install(enforce),
ServiceCmd::Uninstall { purge } => launchd::uninstall(purge),
ServiceCmd::Install { enforce } => service::install(enforce),
ServiceCmd::Uninstall { purge } => service::uninstall(purge),
ServiceCmd::Status => {
println!(
"installed: {}\nrunning: {}\nsocket: {}",
launchd::is_installed(),
launchd::is_running(),
service::is_installed(),
service::is_running(),
ipc::default_socket_path().display()
);
Ok(())
Expand Down
16 changes: 13 additions & 3 deletions crates/mcp_detector_daemon/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ use std::path::PathBuf;

const DIR_NAME: &str = "edison-watch-detectord";

/// True when running as the privileged system daemon (euid 0).
/// True when running as the privileged system daemon (euid 0). Always false on
/// non-Unix targets, which have no root/euid model.
pub fn is_root() -> bool {
// SAFETY: geteuid has no preconditions and no failure mode.
unsafe { geteuid() == 0 }
#[cfg(unix)]
{
// SAFETY: geteuid has no preconditions and no failure mode.
unsafe { geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}

/// Root of all daemon state: system-level under root, the user's config dir
Expand Down Expand Up @@ -72,6 +80,7 @@ pub fn ensure_user_dir(user: &str) -> io::Result<()> {
pub fn current_username() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.or_else(|_| std::env::var("USERNAME")) // Windows
.unwrap_or_else(|_| "unknown".to_string())
}

Expand All @@ -81,6 +90,7 @@ pub fn edison_watch_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".edison-watch"))
}

#[cfg(unix)]
unsafe extern "C" {
fn geteuid() -> u32;
}
Loading
Loading