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

+69 -23
View File
@@ -63,14 +63,10 @@ impl Home {
.prefix(&format!("wb-{tag}-"))
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", dir.path())
.output()
.unwrap();
let mut setup = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
setup.args(["setup", "--home"]).arg(dir.path());
clean_env(&mut setup, dir.path());
let out = setup.output().unwrap();
assert!(out.status.success(), "{out:?}");
Self {
dir,
@@ -79,7 +75,7 @@ impl Home {
}
pub fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
ledger_store::root::default_root(self.dir.path())
}
pub fn run(&self) -> PathBuf {
@@ -190,14 +186,14 @@ impl Server {
.append(true)
.open(home.dir.path().join("server.log"))
.unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
let mut command = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
command
.arg("server")
.arg("--root")
.arg(home.root())
.current_dir(cwd)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.current_dir(cwd);
clean_env(&mut command, home.dir.path());
let mut child = command
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
.stdin(Stdio::piped())
@@ -248,10 +244,10 @@ pub fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
/// [`mcp_http_builder`], for the root's `run/` folder `run`.
pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(run, "mcp.sock").unwrap();
let path = short.path().to_path_buf();
std::mem::forget(short);
reqwest::Client::builder().unix_socket(path)
let endpoint = ledger_mcp::ipc::Endpoint::new(run, "mcp.sock").unwrap();
let builder = ledger_host::channel::http_builder(&endpoint);
std::mem::forget(endpoint);
builder
}
/// The settings a settings file's text gives, as books.set_setting changes
@@ -836,20 +832,71 @@ pub fn book_files(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
/// Copy the tree `from` into `to`, private as the server keeps a book
/// (folders 0700, files 0600).
pub fn copy_private(from: &Path, to: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(to).unwrap();
std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o700)).unwrap();
private_folder(to);
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_private(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)).unwrap();
private_file(&dst);
}
}
}
/// Make the folder `dir` private as the server keeps one (0700; on
/// Windows this account's alone, by an owner-only DACL).
pub fn private_folder(dir: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(windows)]
ledger_store::winsec::set_owner_only(dir).unwrap();
}
/// Make the file `path` private as the server keeps one (0600; on
/// Windows this account's alone).
pub fn private_file(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(windows)]
ledger_store::winsec::set_owner_only(path).unwrap();
}
/// A child process's environment cleared down to what a program needs to
/// run and to find `home` as its own: PATH and HOME on Unix; the system
/// folder, a PATH of the system's, the profile and the temporary folder on
/// Windows (a program started with nothing at all can't load its DLLs).
pub fn clean_env(command: &mut Command, home: &Path) {
command.env_clear();
#[cfg(unix)]
{
command.env("PATH", "/usr/bin:/bin").env("HOME", home);
}
#[cfg(windows)]
{
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
let system32 = Path::new(&root).join("System32");
command
.env("SystemRoot", &root)
.env("windir", &root)
.env(
"PATH",
std::env::join_paths([system32, root.clone().into()]).unwrap(),
)
.env("USERPROFILE", home)
.env("HOME", home)
.env("TEMP", std::env::temp_dir())
.env("TMP", std::env::temp_dir());
}
}
/// A test's place: a home (whose folder holds the test's own files, its
/// settings file among them) and the books [`BOOK`] in its root, served
/// by a server [`Place::start`] starts, and starts again on each next
@@ -912,8 +959,7 @@ impl Place {
let dir = self.books_data();
let fresh = !dir.join("journal.jsonl").exists();
if dir.exists() {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
private_folder(&dir);
}
let cwd = self.cwd.borrow().clone();
let cwd = cwd.as_deref().unwrap_or(self.home.dir.path());