feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)

Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge,
the opener, the books server and the command line is gone: they build on
every OS, and the Unix behaviour is unchanged (the existing over-the-wire
suites pass on Linux).

`ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600
sockets as before, peer uid unchanged (the peer functions moved here).
Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>`
made with a DACL of one allow entry for this account's SID, remote
clients refused, the first instance made once (a second server is
refused, as a held lock refuses it on Unix), and 16 idle instances kept
waiting so a client never finds all busy while the server makes the next.
Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's
process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and
compares that process's token user with its own SID; any doubt is "not
us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot
act as the client. A blocking client with a read limit is polled with
PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has
gone reads as the end of the stream, as a closed socket does. Stop
signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has
SIGTERM, SIGINT and SIGHUP.

The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
leaving the opener's job when the job lets it: an agent's job must not
take the host down, as an app's systemd scope must not on Linux) with a
fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A
replaced program is told by size and last write. Git for Windows is found
and a quiet git is stopped with its tree (`taskkill /T`). The test
harness of ledger-cli finds its root by `default_root`, cleans a child's
environment per OS and reaches the server over its pipe; the bridge's
scripted-server tests stay Unix-only.

No new dependency (tokio's named pipes and reqwest's `windows_named_pipe`
were already in the build); `windows-sys` stays in ledger-store.

Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only),
ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`,
Windows variants of the host and start tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:02:12 -06:00
1 parent 5f5dd8f7da
commit bd6448532e
21 files changed
+1471 -410

No files matched your search

+175 -1
View File
@@ -28,18 +28,34 @@
//! ```
use crate::knobs::Knobs;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::time::{Duration, Instant};
/// The host's PATH (INVARIANT 1).
#[cfg(unix)]
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
/// Where systemd-run may be (never looked up on the opener's PATH).
#[cfg(unix)]
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
/// Windows: a process of its own, without this console, its Ctrl+C or its
/// window (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP): what `setsid`
/// is on Unix.
#[cfg(windows)]
pub(crate) const DETACHED_HOST: u32 = 0x0000_0008 | 0x0000_0200;
/// Windows: leave the job the opener runs in, if it lets its processes
/// (CREATE_BREAKAWAY_FROM_JOB): a host must not end with an agent's job,
/// as it must not end with an app's scope on Linux (INVARIANT 3).
#[cfg(windows)]
const BREAKAWAY: u32 = 0x0100_0000;
/// The host's log, in the root's `run/`.
pub const HOST_LOG: &str = "host.log";
/// A log larger than this is moved aside when a host starts.
@@ -60,11 +76,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
}
/// This account's user ID.
#[cfg(unix)]
fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail.
unsafe { libc::geteuid() }
}
#[cfg(unix)]
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
/// runtime folder: absolute, a real folder (no link), owned by this
/// account, and closed to everyone else.
@@ -79,6 +97,7 @@ pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
}
#[cfg(unix)]
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
@@ -88,6 +107,7 @@ pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
}
#[cfg(unix)]
/// This account's runtime folder: the one named in the environment if it
/// is ours and private; else, when the opener's environment has none (a
/// client that scrubs it, as Codex does), the system's for this account,
@@ -100,6 +120,7 @@ fn runtime_dir() -> Option<PathBuf> {
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
}
#[cfg(unix)]
/// The session bus in `runtime`: the one named in the environment if it
/// is there, else its usual socket there, if one exists.
fn session_bus(runtime: &Path) -> Option<String> {
@@ -115,6 +136,7 @@ fn session_bus(runtime: &Path) -> Option<String> {
}
}
#[cfg(unix)]
/// The ssh agent's socket named in `named`, if it is a socket this
/// account owns at an absolute path (INVARIANT 1).
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
@@ -126,6 +148,7 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
.then(|| path.as_os_str().to_os_string())
}
#[cfg(unix)]
/// The host's environment (INVARIANT 1), given the opener's
/// `SSH_AUTH_SOCK`.
fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
@@ -145,6 +168,7 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
env
}
#[cfg(unix)]
/// Whether this process sits in a unit under this account's user manager
/// (an app's scope or service, as uwsm makes for every app), whose stop
/// would take the host with it; going by the unified cgroup line of
@@ -160,6 +184,7 @@ pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
})
}
#[cfg(unix)]
/// systemd-run, if this process is in an app scope and the user manager
/// can be reached (INVARIANT 3).
fn systemd_run() -> Option<&'static str> {
@@ -173,6 +198,7 @@ fn systemd_run() -> Option<&'static str> {
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
}
#[cfg(unix)]
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
pub fn start_host(
@@ -266,9 +292,11 @@ pub fn start_host(
Ok(())
}
#[cfg(unix)]
/// How long systemd-run has to make its scope and run the host.
const SCOPE_WAIT: Duration = Duration::from_secs(2);
#[cfg(unix)]
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
/// host's first line is in its log after `from`, or systemd-run is still
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
@@ -298,6 +326,111 @@ fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -
}
}
/// The host's environment on Windows (INVARIANT 1's counterpart): the
/// system's own variables, the account's profile, and a PATH fixed to the
/// system folders and Git for Windows (the backups run `git`). Nothing
/// else of the opener's passes. (CHOICE, P11c: no authority states a
/// Windows list; this is the least a Windows program and `git` need.)
#[cfg(windows)]
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
let var = |name: &str| std::env::var_os(name);
let system_root: PathBuf = var("SystemRoot")
.or_else(|| var("windir"))
.map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
let program_files: PathBuf =
var("ProgramFiles").map_or_else(|| PathBuf::from(r"C:\Program Files"), PathBuf::from);
let path = std::env::join_paths([
system_root.join("System32"),
system_root.clone(),
program_files.join("Git").join("cmd"),
])
.unwrap_or_default();
let mut env: Vec<(String, std::ffi::OsString)> = vec![
("PATH".into(), path),
("SystemRoot".into(), system_root.clone().into_os_string()),
("windir".into(), system_root.into_os_string()),
("USERPROFILE".into(), home.as_os_str().to_os_string()),
("HOME".into(), home.as_os_str().to_os_string()),
("TEMP".into(), std::env::temp_dir().into_os_string()),
("TMP".into(), std::env::temp_dir().into_os_string()),
];
if let Some(local) = ledger_store::winsec::local_app_data() {
env.push(("LOCALAPPDATA".into(), local.into_os_string()));
}
for name in ["APPDATA", "ComSpec", "PATHEXT", "ProgramFiles"] {
if let Some(value) = var(name) {
env.push((name.into(), value));
}
}
env
}
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs and these server flags (INVARIANTS 1, 2, 4
/// and 5; there is no scope to leave, but a job to break away from).
#[cfg(windows)]
pub fn start_host(
root: Option<&Path>,
knobs: &Knobs,
server_flags: &[String],
) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
let exe = this_executable()?;
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
if root.is_some() {
args.push("--root".into());
args.push(found.path().as_os_str().to_os_string());
}
args.extend(knobs.args().into_iter().map(Into::into));
args.extend(server_flags.iter().map(Into::into));
let log = || {
let path = run.join(HOST_LOG);
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
&& !crate::opener::host_lock_held(&run)
{
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
std::fs::OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&path)
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
};
let spawn = |flags: u32| -> Result<std::process::Child, String> {
Command::new(&exe)
.args(&args)
.env_clear()
.envs(host_env(&home))
.current_dir(&run)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(log()?)
.creation_flags(flags)
.spawn()
.map_err(|e| {
// Named for the caller below: breakaway refused.
if e.kind() == std::io::ErrorKind::PermissionDenied {
"denied".to_string()
} else {
format!("can't start the books' host: {e}")
}
})
};
let child = match spawn(DETACHED_HOST | BREAKAWAY) {
Ok(child) => child,
// The job the opener runs in doesn't let a process leave it: the
// host starts inside it, as a host without a scope does on Linux.
Err(why) if why == "denied" => spawn(DETACHED_HOST)?,
Err(why) => return Err(why),
};
reap(child);
Ok(())
}
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
fn reap(mut child: std::process::Child) {
let _ = std::thread::Builder::new()
@@ -307,7 +440,7 @@ fn reap(mut child: std::process::Child) {
});
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
@@ -457,3 +590,44 @@ mod tests {
assert!(exe.is_file(), "{exe:?}");
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
#[test]
fn this_executable_is_a_file_on_disk() {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
#[test]
fn the_host_environment_carries_nothing_of_the_openers_choosing() {
let env = host_env(Path::new(r"C:\Users\someone"));
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
for n in &names {
assert!(
[
"PATH",
"SystemRoot",
"windir",
"USERPROFILE",
"HOME",
"TEMP",
"TMP",
"LOCALAPPDATA",
"APPDATA",
"ComSpec",
"PATHEXT",
"ProgramFiles"
]
.contains(n),
"{n}"
);
}
let home = env.iter().find(|(n, _)| n == "HOME").unwrap();
assert_eq!(home.1, r"C:\Users\someone");
let path = env.iter().find(|(n, _)| n == "PATH").unwrap();
assert!(path.1.to_string_lossy().to_lowercase().contains("system32"));
}
}