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

+10 -10
View File
@@ -109,8 +109,8 @@ pub struct Opener {
pub struct Lease {
/// Its number.
pub id: u64,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
_write: tokio::net::unix::OwnedWriteHalf,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<ledger_mcp::ipc::ConnectedRead>>>,
_write: ledger_mcp::ipc::ConnectedWrite,
}
impl Lease {
@@ -207,11 +207,11 @@ impl Opener {
let mut started: Option<Instant> = None;
loop {
wait_for_restart(&self.run).await;
let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET)
let endpoint = ledger_mcp::ipc::Endpoint::new(&self.run, lease::LEASE_SOCKET)
.map_err(|e| format!("can't reach the books' host: {e}"))?;
match tokio::net::UnixStream::connect(short.path()).await {
match ledger_mcp::ipc::connect(&endpoint).await {
Ok(stream) => {
if !ledger_mcp::local::peer_is_us(&stream) {
if !ledger_mcp::ipc::peer_is_us(&stream) {
return Err(
"the books' host socket belongs to another account: not used".into(),
);
@@ -279,8 +279,8 @@ impl Opener {
}
/// Say hello on a connection and read the host's first word.
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
let (read, mut write) = stream.into_split();
async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result<Asked, String> {
let (read, mut write) = ledger_mcp::ipc::split_connected(stream);
let hello = Hello {
proto: lease::PROTO,
ask: Ask::Hold {
@@ -402,14 +402,14 @@ pub fn ask_once(run: &Path, ask: Ask) -> Result<Option<HostMsg>, String> {
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
use std::io::{BufRead, Write};
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET)
.map_err(|e| format!("can't reach the books' host: {e}"))?;
let stream = match std::os::unix::net::UnixStream::connect(short.path()) {
let stream = match ledger_mcp::ipc::connect_std(&endpoint) {
Ok(s) => s,
Err(e) if no_host(&e) => return Ok(None),
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
};
if !ledger_mcp::local::peer_is_us(&stream) {
if !ledger_mcp::ipc::peer_is_us(&stream) {
return Err("the books' host socket belongs to another account: not used".into());
}
stream