1	//! M11-W3 shell-tunnel E2E (REQ-SHELL-4) — the opaque byte tunnel proven through
2	//! the REAL `spt` surfaces + a real broker-owned NetHost, no in-process shortcut
3	//! on the relay path:
4	//!
5	//! spawn (offline) → `api bind-shell` onlines the perch AND opens the tunnel
6	//! (manifest `[shell.tunnel].enable`) → `spt shell tunnel <ref> send` pipes raw
7	//! bytes in → `api tunnel <id> recv --link` drains them out (and the reverse). The
8	//! bytes ride the broker-held loopback QUIC stream pair (same-node mock; the
9	//! cross-node-on-LAN real-Iroh wire is the W5 [twohost] rung — NOT proven here).
10	//!
11	//! Proves: (1) byte-exact opaque round-trip BOTH directions of a MULTI-WRITE,
12	//! sizable payload that includes `<EVENT`-looking bytes + NULs the envelope grammar
13	//! would mangle — the taxonomy never reinterprets them; (2) link-break closes the
14	//! tunnel (a post-break resolve finds none); (3) no-stale-stream-across-relink (R1)
15	//! — bytes pending in the pre-break generation are NEVER surfaced to the relinked
16	//! instance's fresh tunnel.
17	//
18	// [int->REQ-SHELL-4]
19
20	use std::io::Write;
21	use std::path::PathBuf;
22	use std::process::{Command, Output, Stdio};
23	use std::sync::Arc;
24	use std::thread;
25	use std::time::Duration;
26
27	mod common;
28	use common::CommandNoWindowExt;
29
30	use spt_daemon::endpoint::{broker_socket_name, tunnel_socket_name};
31	use spt_daemon::nethost::{NetConfig, NetHost};
32	use spt_daemon::{Broker, TunnelHub};
33	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
34	use spt_proto::identity::Identity;
35	use spt_store::perch::{self, ParentHint};
36
37	fn hermetic(identity: Identity) -> NetConfig {
38	    NetConfig {
39	        identity,
40	        relay: RelayPolicy::Disabled,
41	        discovery: LocalDiscovery::Off,
42	        scope: BindScope::Loopback,
43	        membership: None,
44	    }
45	}
46
47	/// Stand up the daemon surfaces this E2E needs on the current `SPT_HOME`'s
48	/// sockets: the **seed** control (so subprocess `ensure_running` pings succeed
49	/// without a real detached daemon), a **broker that owns a hermetic NetHost** (the
50	/// tunnel relay opens loopback streams on it), and the **tunnel** control bound to
51	/// that broker. All serve threads are leaked; they die with the test process.
52	fn start_inproc_daemon(dir: &std::path::Path) {
53	    let reg = Arc::new(spt_daemon::SeedRegistry::new());
54	    let seed = spt_daemon::seed_socket_name();
55	    thread::spawn(move || {
56	        let _ = spt_daemon::seedmap::serve_seed_control(&seed, reg);
57	    });
58
59	    let host = NetHost::start(hermetic(Identity::generate())).expect("net host start");
60	    let broker = Broker::bind_in_with_net(&broker_socket_name(), dir.join("effects.log"), Some(host))
61	        .expect("bind broker with net");
62	    let serve = Arc::clone(&broker);
63	    thread::spawn(move || {
64	        let _ = serve.serve();
65	    });
66
67	    let hub = Arc::new(TunnelHub::new());
68	    let tsock = tunnel_socket_name();
69	    thread::spawn(move || {
70	        let _ = spt_daemon::serve_tunnel_control(&tsock, hub, broker);
71	    });
72
73	    for _ in 0..400 {
74	        if spt_daemon::is_running() {
75	            return;
76	        }
77	        thread::sleep(Duration::from_millis(5));
78	    }
79	    panic!("in-process seed daemon did not come up");
80	}
81
82	/// `Command::output()` with a deadline, off-thread (the drive_e2e pattern).
83	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
84	    let (tx, rx) = std::sync::mpsc::channel();
85	    thread::spawn(move || {
86	        let _ = tx.send(cmd.output());
87	    });
88	    rx.recv_timeout(deadline)
89	        .expect("captured spt call must complete")
90	        .expect("run spt")
91	}
92
93	/// Spawn `cmd` with `input` on stdin, capture output, bounded.
94	fn output_with_stdin(mut cmd: Command, input: Vec<u8>, deadline: Duration) -> Output {
95	    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
96	    let (tx, rx) = std::sync::mpsc::channel();
97	    thread::spawn(move || {
98	        let res = (|| {
99	            let mut child = cmd.spawn()?;
100	            child.stdin.take().unwrap().write_all(&input)?;
101	            // stdin dropped here → EOF, so the child's read_to_end completes.
102	            child.wait_with_output()
103	        })();
104	        let _ = tx.send(res);
105	    });
106	    rx.recv_timeout(deadline)
107	        .expect("captured spt stdin call must complete")
108	        .expect("run spt with stdin")
109	}
110
111	fn register_adapter(name: &str, manifest: &str) {
112	    let src = perch::spt_home().join("srcs").join(name);
113	    std::fs::create_dir_all(&src).unwrap();
114	    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
115	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
116	}
117
118	#[test]
119	fn shell_tunnel_round_trips_opaque_bytes_through_the_real_surfaces() {
120	    let home = tempfile::tempdir().unwrap();
121	    std::env::set_var("SPT_HOME", home.path());
122	    start_inproc_daemon(home.path());
123
124	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
125	    let spt = |args: &[&str]| {
126	        let mut cmd = Command::new(&spt_bin);
127	        cmd.no_window().args(args).env("SPT_HOME", home.path());
128	        output_bounded(cmd, Duration::from_secs(60))
129	    };
130	    let spt_stdin = |args: &[&str], input: Vec<u8>| {
131	        let mut cmd = Command::new(&spt_bin);
132	        cmd.no_window().args(args).env("SPT_HOME", home.path());
133	        output_with_stdin(cmd, input, Duration::from_secs(60))
134	    };
135
136	    // A shell adapter that OPTS IN to the tunnel. The spawn is a noop that exits
137	    // immediately (never binds) — the perch stays offline until we online it by
138	    // token, so we control the online/offline edges precisely.
139	    #[cfg(windows)]
140	    let noop = "cmd /c exit 0";
141	    #[cfg(unix)]
142	    let noop = "true";
143	    register_adapter(
144	        "mock-shell",
145	        &format!(
146	            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
147	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop} {{link_token}} {{id}}'\n\n\
148	             [shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n"
149	        ),
150	    );
151
152	    // The owner: a bound Self perch + node identity.
153	    let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
154	    std::fs::create_dir_all(&owner_perch).unwrap();
155	    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
156	    spt_store::info::write_info(&owner_perch, &rec).unwrap();
157	    spt_store::nodeid::load_or_create().expect("node identity");
158
159	    let owlery = perch::owlery_dir();
160	    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
161	    let token_of =
162	        || spt_daemon::shellhost::read_link_token(&shell_perch).expect("a link token is parked");
163	    let online_by_token = |token: &str| {
164	        let out = spt(&["api", "--adapter", "mock-shell", "bind-shell", "--link", token]);
165	        assert!(
166	            out.status.success(),
167	            "bind-shell: {}",
168	            String::from_utf8_lossy(&out.stderr)
169	        );
170	        assert!(
171	            String::from_utf8_lossy(&out.stderr).contains("SHELL_TUNNEL_OPEN"),
172	            "bind-shell opens the tunnel for a [shell.tunnel] adapter: {}",
173	            String::from_utf8_lossy(&out.stderr)
174	        );
175	    };
176	    // Owner sends raw bytes into the tunnel.
177	    let owner_send = |bytes: Vec<u8>| {
178	        let out = spt_stdin(&["shell", "tunnel", "Scout", "send", "--owner", "doyle"], bytes);
179	        assert!(
180	            out.status.success(),
181	            "owner tunnel send: {}",
182	            String::from_utf8_lossy(&out.stderr)
183	        );
184	    };
185	    let shell_send = |token: &str, bytes: Vec<u8>| {
186	        let out = spt_stdin(
187	            &["api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "send", "--link", token],
188	            bytes,
189	        );
190	        assert!(
191	            out.status.success(),
192	            "shell tunnel send: {}",
193	            String::from_utf8_lossy(&out.stderr)
194	        );
195	    };
196	    // Drain one end, retrying until `want` bytes have crossed the async pump (or a
197	    // deadline). Returns everything accumulated.
198	    let shell_recv_until = |token: &str, want: usize| -> Vec<u8> {
199	        let mut got = Vec::new();
200	        for _ in 0..400 {
201	            let out = spt(&[
202	                "api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "recv", "--link", token,
203	            ]);
204	            got.extend_from_slice(&out.stdout);
205	            if got.len() >= want {
206	                break;
207	            }
208	            thread::sleep(Duration::from_millis(10));
209	        }
210	        got
211	    };
212	    let owner_recv_until = |want: usize| -> Vec<u8> {
213	        let mut got = Vec::new();
214	        for _ in 0..400 {
215	            let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
216	            got.extend_from_slice(&out.stdout);
217	            if got.len() >= want {
218	                break;
219	            }
220	            thread::sleep(Duration::from_millis(10));
221	        }
222	        got
223	    };
224
225	    // ── spawn (offline) + online by token → the tunnel opens.
226	    let out = spt(&["shell", "spawn", "mock-shell", "--alias", "Scout", "--owner", "doyle"]);
227	    assert!(out.status.success(), "spawn: {}", String::from_utf8_lossy(&out.stderr));
228	    let token_a = token_of();
229	    online_by_token(&token_a);
230
231	    // A payload the <EVENT> envelope grammar would mangle: NULs, an `<EVENT`-looking
232	    // run, and high bytes — across TWO writes plus a sizable third that spans many
233	    // read-pump chunks (exposes any ordering/eviction bug across the poll boundary).
234	    let blob1: Vec<u8> = b"\x00<EVENT type=\"msg\" from=\"x\">not really an event\xff\xfe\x00".to_vec();
235	    let blob2: Vec<u8> = (0..=255u8).cycle().take(200 * 1024).collect();
236	    let want: Vec<u8> = blob1.iter().chain(blob2.iter()).copied().collect();
237
238	    // ── (1a) owner → shell, MULTI-WRITE, byte-exact.
239	    owner_send(blob1.clone());
240	    owner_send(blob2.clone());
241	    let got = shell_recv_until(&token_a, want.len());
242	    assert_eq!(got.len(), want.len(), "shell drained every owner→shell byte (no loss/dup)");
243	    assert_eq!(got, want, "owner→shell opaque bytes round-trip byte-exact, in order");
244
245	    // ── (1b) shell → owner, byte-exact (the reverse leg of the duplex).
246	    let reply: Vec<u8> = b"\x00\x01\x02<EVENT/>\xaa\xbb reply payload".to_vec();
247	    shell_send(&token_a, reply.clone());
248	    let back = owner_recv_until(reply.len());
249	    assert_eq!(back, reply, "shell→owner opaque bytes round-trip byte-exact");
250
251	    // ── (2)+(3) link-break closes the tunnel, and no pre-break byte survives the
252	    // relink (R1). Send a pending payload, DON'T drain it, break the link, relink to
253	    // a fresh generation, and confirm the relinked tunnel never serves the stale
254	    // bytes.
255	    shell_send(&token_a, b"STALE-PRE-BREAK-BYTES".to_vec()); // pending in the owner end's ring
256	    let shell = spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mock-shell")
257	        .unwrap()
258	        .1
259	        .shell
260	        .unwrap();
261	    spt_daemon::shellhost::close_shell(&owlery, "doyle", "mock-shell-0", Some(&shell))
262	        .expect("link break closes the tunnel");
263
264	    // A post-break resolve finds no tunnel (the entry was cleared on link-break).
265	    let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
266	    assert!(
267	        String::from_utf8_lossy(&out.stderr).contains("NO_TUNNEL"),
268	        "the link break closed the tunnel: {}",
269	        String::from_utf8_lossy(&out.stderr)
270	    );
271
272	    // Relink (fresh token) + online → a FRESH tunnel; the stale pre-break bytes are
273	    // never surfaced to it.
274	    let out = spt(&["shell", "relink", "Scout", "--owner", "doyle"]);
275	    assert!(out.status.success(), "relink: {}", String::from_utf8_lossy(&out.stderr));
276	    let token_b = token_of();
277	    assert_ne!(token_a, token_b, "relink rotates the link token");
278	    online_by_token(&token_b);
279
280	    // Drain the fresh owner end a few times — the stale "STALE-PRE-BREAK-BYTES" from
281	    // the dead generation must NEVER appear (no-stale-stream-across-relink, R1).
282	    let mut fresh = Vec::new();
283	    for _ in 0..30 {
284	        let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
285	        fresh.extend_from_slice(&out.stdout);
286	        thread::sleep(Duration::from_millis(5));
287	    }
288	    assert!(
289	        !String::from_utf8_lossy(&fresh).contains("STALE-PRE-BREAK-BYTES"),
290	        "no pre-break byte survives the link break + relink (R1): {:?}",
291	        String::from_utf8_lossy(&fresh)
292	    );
293
294	    // Best-effort hygiene: reap any real daemon an `ensure_running` race spawned
295	    // against this throwaway home (workspace-scoped, never machine-wide).
296	    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
297	        if let Ok(pid) = pid.trim().parse::<u32>() {
298	            #[cfg(windows)]
299	            let _ = Command::new("taskkill")
300	                .no_window()
301	                .args(["/PID", &pid.to_string(), "/F"])
302	                .output();
303	            #[cfg(unix)]
304	            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
305	        }
306	    }
307	}
308

//! M11-W3 shell-tunnel E2E (REQ-SHELL-4) — the opaque byte tunnel proven through
//! the REAL `spt` surfaces + a real broker-owned NetHost, no in-process shortcut
//! on the relay path:
//!
//! spawn (offline) → `api bind-shell` onlines the perch AND opens the tunnel
//! (manifest `[shell.tunnel].enable`) → `spt shell tunnel <ref> send` pipes raw
//! bytes in → `api tunnel <id> recv --link` drains them out (and the reverse). The
//! bytes ride the broker-held loopback QUIC stream pair (same-node mock; the
//! cross-node-on-LAN real-Iroh wire is the W5 [twohost] rung — NOT proven here).
//!
//! Proves: (1) byte-exact opaque round-trip BOTH directions of a MULTI-WRITE,
//! sizable payload that includes `<EVENT`-looking bytes + NULs the envelope grammar
//! would mangle — the taxonomy never reinterprets them; (2) link-break closes the
//! tunnel (a post-break resolve finds none); (3) no-stale-stream-across-relink (R1)
//! — bytes pending in the pre-break generation are NEVER surfaced to the relinked
//! instance's fresh tunnel.
//
// [int->REQ-SHELL-4]

use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::endpoint::{broker_socket_name, tunnel_socket_name};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::{Broker, TunnelHub};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;
use spt_store::perch::{self, ParentHint};

fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Stand up the daemon surfaces this E2E needs on the current `SPT_HOME`'s
/// sockets: the **seed** control (so subprocess `ensure_running` pings succeed
/// without a real detached daemon), a **broker that owns a hermetic NetHost** (the
/// tunnel relay opens loopback streams on it), and the **tunnel** control bound to
/// that broker. All serve threads are leaked; they die with the test process.
fn start_inproc_daemon(dir: &std::path::Path) {
    let reg = Arc::new(spt_daemon::SeedRegistry::new());
    let seed = spt_daemon::seed_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&seed, reg);
    });

    let host = NetHost::start(hermetic(Identity::generate())).expect("net host start");
    let broker = Broker::bind_in_with_net(&broker_socket_name(), dir.join("effects.log"), Some(host))
        .expect("bind broker with net");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let hub = Arc::new(TunnelHub::new());
    let tsock = tunnel_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::serve_tunnel_control(&tsock, hub, broker);
    });

    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, off-thread (the drive_e2e pattern).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

/// Spawn `cmd` with `input` on stdin, capture output, bounded.
fn output_with_stdin(mut cmd: Command, input: Vec<u8>, deadline: Duration) -> Output {
    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let res = (|| {
            let mut child = cmd.spawn()?;
            child.stdin.take().unwrap().write_all(&input)?;
            // stdin dropped here → EOF, so the child's read_to_end completes.
            child.wait_with_output()
        })();
        let _ = tx.send(res);
    });
    rx.recv_timeout(deadline)
        .expect("captured spt stdin call must complete")
        .expect("run spt with stdin")
}

fn register_adapter(name: &str, manifest: &str) {
    let src = perch::spt_home().join("srcs").join(name);
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
}

#[test]
fn shell_tunnel_round_trips_opaque_bytes_through_the_real_surfaces() {
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon(home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };
    let spt_stdin = |args: &[&str], input: Vec<u8>| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_with_stdin(cmd, input, Duration::from_secs(60))
    };

    // A shell adapter that OPTS IN to the tunnel. The spawn is a noop that exits
    // immediately (never binds) — the perch stays offline until we online it by
    // token, so we control the online/offline edges precisely.
    #[cfg(windows)]
    let noop = "cmd /c exit 0";
    #[cfg(unix)]
    let noop = "true";
    register_adapter(
        "mock-shell",
        &format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop} {{link_token}} {{id}}'\n\n\
             [shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n"
        ),
    );

    // The owner: a bound Self perch + node identity.
    let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
    std::fs::create_dir_all(&owner_perch).unwrap();
    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
    spt_store::info::write_info(&owner_perch, &rec).unwrap();
    spt_store::nodeid::load_or_create().expect("node identity");

    let owlery = perch::owlery_dir();
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
    let token_of =
        || spt_daemon::shellhost::read_link_token(&shell_perch).expect("a link token is parked");
    let online_by_token = |token: &str| {
        let out = spt(&["api", "--adapter", "mock-shell", "bind-shell", "--link", token]);
        assert!(
            out.status.success(),
            "bind-shell: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        assert!(
            String::from_utf8_lossy(&out.stderr).contains("SHELL_TUNNEL_OPEN"),
            "bind-shell opens the tunnel for a [shell.tunnel] adapter: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    };
    // Owner sends raw bytes into the tunnel.
    let owner_send = |bytes: Vec<u8>| {
        let out = spt_stdin(&["shell", "tunnel", "Scout", "send", "--owner", "doyle"], bytes);
        assert!(
            out.status.success(),
            "owner tunnel send: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    };
    let shell_send = |token: &str, bytes: Vec<u8>| {
        let out = spt_stdin(
            &["api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "send", "--link", token],
            bytes,
        );
        assert!(
            out.status.success(),
            "shell tunnel send: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    };
    // Drain one end, retrying until `want` bytes have crossed the async pump (or a
    // deadline). Returns everything accumulated.
    let shell_recv_until = |token: &str, want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&[
                "api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "recv", "--link", token,
            ]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };
    let owner_recv_until = |want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };

    // ── spawn (offline) + online by token → the tunnel opens.
    let out = spt(&["shell", "spawn", "mock-shell", "--alias", "Scout", "--owner", "doyle"]);
    assert!(out.status.success(), "spawn: {}", String::from_utf8_lossy(&out.stderr));
    let token_a = token_of();
    online_by_token(&token_a);

    // A payload the <EVENT> envelope grammar would mangle: NULs, an `<EVENT`-looking
    // run, and high bytes — across TWO writes plus a sizable third that spans many
    // read-pump chunks (exposes any ordering/eviction bug across the poll boundary).
    let blob1: Vec<u8> = b"\x00<EVENT type=\"msg\" from=\"x\">not really an event\xff\xfe\x00".to_vec();
    let blob2: Vec<u8> = (0..=255u8).cycle().take(200 * 1024).collect();
    let want: Vec<u8> = blob1.iter().chain(blob2.iter()).copied().collect();

    // ── (1a) owner → shell, MULTI-WRITE, byte-exact.
    owner_send(blob1.clone());
    owner_send(blob2.clone());
    let got = shell_recv_until(&token_a, want.len());
    assert_eq!(got.len(), want.len(), "shell drained every owner→shell byte (no loss/dup)");
    assert_eq!(got, want, "owner→shell opaque bytes round-trip byte-exact, in order");

    // ── (1b) shell → owner, byte-exact (the reverse leg of the duplex).
    let reply: Vec<u8> = b"\x00\x01\x02<EVENT/>\xaa\xbb reply payload".to_vec();
    shell_send(&token_a, reply.clone());
    let back = owner_recv_until(reply.len());
    assert_eq!(back, reply, "shell→owner opaque bytes round-trip byte-exact");

    // ── (2)+(3) link-break closes the tunnel, and no pre-break byte survives the
    // relink (R1). Send a pending payload, DON'T drain it, break the link, relink to
    // a fresh generation, and confirm the relinked tunnel never serves the stale
    // bytes.
    shell_send(&token_a, b"STALE-PRE-BREAK-BYTES".to_vec()); // pending in the owner end's ring
    let shell = spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mock-shell")
        .unwrap()
        .1
        .shell
        .unwrap();
    spt_daemon::shellhost::close_shell(&owlery, "doyle", "mock-shell-0", Some(&shell))
        .expect("link break closes the tunnel");

    // A post-break resolve finds no tunnel (the entry was cleared on link-break).
    let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_TUNNEL"),
        "the link break closed the tunnel: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Relink (fresh token) + online → a FRESH tunnel; the stale pre-break bytes are
    // never surfaced to it.
    let out = spt(&["shell", "relink", "Scout", "--owner", "doyle"]);
    assert!(out.status.success(), "relink: {}", String::from_utf8_lossy(&out.stderr));
    let token_b = token_of();
    assert_ne!(token_a, token_b, "relink rotates the link token");
    online_by_token(&token_b);

    // Drain the fresh owner end a few times — the stale "STALE-PRE-BREAK-BYTES" from
    // the dead generation must NEVER appear (no-stale-stream-across-relink, R1).
    let mut fresh = Vec::new();
    for _ in 0..30 {
        let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", "doyle"]);
        fresh.extend_from_slice(&out.stdout);
        thread::sleep(Duration::from_millis(5));
    }
    assert!(
        !String::from_utf8_lossy(&fresh).contains("STALE-PRE-BREAK-BYTES"),
        "no pre-break byte survives the link break + relink (R1): {:?}",
        String::from_utf8_lossy(&fresh)
    );

    // Best-effort hygiene: reap any real daemon an `ensure_running` race spawned
    // against this throwaway home (workspace-scoped, never machine-wide).
    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(pid) = pid.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &pid.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
        }
    }
}


1	//! M9-T5 Gateway type acceptance E2E (REQ-EP-6): a **Gateway-typed endpoint**,
2	//! driven by a real adapter manifest + the generic mock-session binary, binds
3	//! through the open type system and is the user-msg identity gate's user-backed
4	//! origin — all through the real `spt` binary, no downstream (lecturn) code.
5	//!
6	//! Flow:
7	//!   1. The `mock-gateway` manifest's `[session.self]` spawns `mock-session`,
8	//!      which fires `api bind --type gateway` → the perch binds `state="gateway"`
9	//!      (the un-hardcode: a non-agent endpoint binds via the open type system).
10	//!   2. A `user-msg` SENT FROM the gateway (session-proven origin) is HONORED —
11	//!      the peer's spool carries a `<EVENT type="user-msg">` envelope.
12	//!   3. The SAME `--user-msg` request from a `live_agent` session is RE-STAMPED
13	//!      to a plain body (the agent-family row of the identity gate).
14	//!
15	//! Together with the daemon WAN funnel test (fail-closed cross-node) and the
16	//! spt-proto truth table, this closes the §user-msg identity gate end-to-end.
17	//
18	// [int->REQ-EP-6]
19	// [int->REQ-MSG-5]
20
21	use std::io::Write;
22	use std::path::{Path, PathBuf};
23	use std::process::{Command, Stdio};
24	use std::sync::Mutex;
25	use std::time::Duration;
26
27	mod common;
28	use common::CommandNoWindowExt;
29
30	use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};
31	use spt_store::perch::{self, ParentHint};
32	use spt_store::spool;
33
34	/// Serialize the process-global `SPT_HOME` mutation across tests in this binary.
35	static E2E_LOCK: Mutex<()> = Mutex::new(());
36
37	fn sibling_bin(name: &str) -> PathBuf {
38	    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
39	    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
40	}
41
42	fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
43	    pairs
44	        .iter()
45	        .map(|(k, v)| (k.to_string(), v.to_string()))
46	        .collect()
47	}
48
49	/// In-process seed daemon on the current `SPT_HOME` so child `spt api …`
50	/// invocations find a running daemon (no real `spt daemon` spawned).
51	fn start_inproc_daemon() {
52	    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
53	    let name = spt_daemon::seed_socket_name();
54	    std::thread::spawn(move || {
55	        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
56	    });
57	    for _ in 0..400 {
58	        if spt_daemon::is_running() {
59	            return;
60	        }
61	        std::thread::sleep(Duration::from_millis(5));
62	    }
63	    panic!("in-process seed daemon did not come up");
64	}
65
66	/// Create an offline perch (dir + info.json, no listener) so a send to it spools.
67	fn offline_peer(id: &str) -> PathBuf {
68	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
69	    std::fs::create_dir_all(&perch_path).unwrap();
70	    let rec = spt_store::info::InfoJson::new(id, "now", std::process::id(), "peer-sess", "ready_agent");
71	    spt_store::info::write_info(&perch_path, &rec).unwrap();
72	    perch_path
73	}
74
75	/// Run `spt send <target> --from <from> --user-msg` with `OWL_SESSION_ID` set
76	/// (so the gate's session-proven origin resolves), piping `body` on stdin.
77	fn send_user_msg(
78	    spt: &Path,
79	    home: &Path,
80	    target: &str,
81	    from: &str,
82	    session_id: &str,
83	    body: &str,
84	) -> bool {
85	    let mut child = Command::new(spt)
86	        .no_window()
87	        .args(["send", target, "--from", from, "--user-msg"])
88	        .env("SPT_HOME", home)
89	        .env("OWL_SESSION_ID", session_id)
90	        .stdin(Stdio::piped())
91	        .stdout(Stdio::piped())
92	        .stderr(Stdio::piped())
93	        .spawn()
94	        .expect("spawn spt send");
95	    child
96	        .stdin
97	        .take()
98	        .unwrap()
99	        .write_all(body.as_bytes())
100	        .unwrap();
101	    child.wait_with_output().expect("send output").status.success()
102	}
103
104	/// The single spooled body for `perch_path` (oldest), or panic.
105	fn spooled_body(perch_path: &Path) -> String {
106	    let pending = spool::peek_all_at(perch_path).expect("peek");
107	    pending
108	        .first()
109	        .map(|(_, _, body, _)| body.clone())
110	        .expect("a spooled row")
111	}
112
113	#[test]
114	fn gateway_binds_and_is_the_user_backed_origin() {
115	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
116	    let home = tempfile::tempdir().unwrap();
117	    std::env::set_var("SPT_HOME", home.path());
118	    start_inproc_daemon();
119
120	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
121	    let mock_bin = sibling_bin("mock-session");
122	    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());
123
124	    // Load the mock-gateway manifest; point [session.self] at the built binaries.
125	    let manifest_path = concat!(
126	        env!("CARGO_MANIFEST_DIR"),
127	        "/../../adapters/mock-gateway/manifest.toml"
128	    );
129	    let mut manifest = Manifest::from_toml_str(
130	        &std::fs::read_to_string(manifest_path).expect("read mock-gateway manifest"),
131	    )
132	    .expect("mock-gateway manifest valid");
133	    assert_eq!(
134	        manifest.adapter.hostable_types,
135	        vec!["Gateway"],
136	        "the gateway adapter declares the Gateway hostable type"
137	    );
138	    manifest.session.self_.as_mut().unwrap().command = format!(
139	        "\"{}\" --id {{id}} --session-id {{session_id}} --type gateway --mock-adapter mock-gateway --spt \"{}\"",
140	        mock_bin.display(),
141	        spt_bin.display(),
142	    );
143
144	    // 1. Spawn the gateway session (bounded) → it fires `api bind --type gateway`.
145	    let gw = "playdate-gw";
146	    let gw_session = "sid-gw-1";
147	    let runtime = ManifestRuntime::new(manifest);
148	    let out = runtime
149	        .run_bounded(
150	            "self",
151	            &keys(&[("id", gw), ("session_id", gw_session)]),
152	            Duration::from_secs(30),
153	        )
154	        .expect("spawn mock gateway");
155	    assert!(
156	        out.success(),
157	        "mock-gateway session failed: code={:?}\nstdout={}\nstderr={}",
158	        out.status_code,
159	        out.stdout,
160	        out.stderr
161	    );
162
163	    // The perch bound as a GATEWAY (the un-hardcode: NOT forced to live_agent).
164	    let gw_perch = perch::resolve_perch_path(gw, ParentHint::Infer);
165	    let rec = spt_store::info::read_info(&gw_perch).expect("gateway info.json after bind");
166	    assert_eq!(rec.session_id, gw_session);
167	    assert_eq!(rec.state, "gateway", "a Gateway binds with its open-type tag");
168
169	    // 2. A user-msg SENT FROM the gateway is HONORED (the user-backed origin):
170	    //    the peer's spool carries a verbatim user-msg envelope.
171	    let peer_u = "peer-of-gateway";
172	    let peer_u_perch = offline_peer(peer_u);
173	    assert!(
174	        send_user_msg(&spt_bin, home.path(), peer_u, gw, gw_session, "ship it now"),
175	        "send from the gateway succeeds"
176	    );
177	    let body = spooled_body(&peer_u_perch);
178	    let p = spt_proto::event::parse_event(&body)
179	        .expect("the gateway's user-msg spooled as a typed envelope");
180	    assert_eq!(
181	        p.event_type.as_deref(),
182	        Some("user-msg"),
183	        "Gateway origin → user-msg HONORED, got body: {body:?}"
184	    );
185	    assert_eq!(p.body, "ship it now");
186
187	    // 3. The SAME --user-msg request from a live_agent session is RE-STAMPED.
188	    //    Bind an ordinary agent perch and send as it.
189	    let agent = "ordinary-agent";
190	    let agent_session = "sid-agent-1";
191	    let bind = Command::new(&spt_bin)
192	        .no_window()
193	        .args([
194	            "api",
195	            "--adapter",
196	            "mock",
197	            "bind",
198	            agent,
199	            "--set-session-id",
200	            agent_session,
201	        ])
202	        .env("SPT_HOME", home.path())
203	        .status()
204	        .expect("bind agent");
205	    assert!(bind.success(), "agent bind");
206	    let agent_perch = perch::resolve_perch_path(agent, ParentHint::Infer);
207	    assert_eq!(
208	        spt_store::info::read_info(&agent_perch).unwrap().state,
209	        "live_agent"
210	    );
211
212	    let peer_a = "peer-of-agent";
213	    let peer_a_perch = offline_peer(peer_a);
214	    assert!(
215	        send_user_msg(&spt_bin, home.path(), peer_a, agent, agent_session, "do this"),
216	        "send from the agent succeeds (degraded, never rejected)"
217	    );
218	    let body = spooled_body(&peer_a_perch);
219	    // Re-stamped: the body is the plain text, NOT a user-msg envelope. (It
220	    // renders as type="msg" at the listener surface — the re-stamp.)
221	    assert!(
222	        spt_proto::event::parse_event(&body)
223	            .and_then(|p| p.event_type)
224	            .as_deref()
225	            != Some("user-msg"),
226	        "agent-family origin → user-msg RE-STAMPED (not a user-msg envelope), got: {body:?}"
227	    );
228	    assert_eq!(body, "do this", "the plain body rides as msg");
229	}
230

//! M9-T5 Gateway type acceptance E2E (REQ-EP-6): a **Gateway-typed endpoint**,
//! driven by a real adapter manifest + the generic mock-session binary, binds
//! through the open type system and is the user-msg identity gate's user-backed
//! origin — all through the real `spt` binary, no downstream (lecturn) code.
//!
//! Flow:
//!   1. The `mock-gateway` manifest's `[session.self]` spawns `mock-session`,
//!      which fires `api bind --type gateway` → the perch binds `state="gateway"`
//!      (the un-hardcode: a non-agent endpoint binds via the open type system).
//!   2. A `user-msg` SENT FROM the gateway (session-proven origin) is HONORED —
//!      the peer's spool carries a `<EVENT type="user-msg">` envelope.
//!   3. The SAME `--user-msg` request from a `live_agent` session is RE-STAMPED
//!      to a plain body (the agent-family row of the identity gate).
//!
//! Together with the daemon WAN funnel test (fail-closed cross-node) and the
//! spt-proto truth table, this closes the §user-msg identity gate end-to-end.
//
// [int->REQ-EP-6]
// [int->REQ-MSG-5]

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Serialize the process-global `SPT_HOME` mutation across tests in this binary.
static E2E_LOCK: Mutex<()> = Mutex::new(());

fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
    pairs
        .iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect()
}

/// In-process seed daemon on the current `SPT_HOME` so child `spt api …`
/// invocations find a running daemon (no real `spt daemon` spawned).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// Create an offline perch (dir + info.json, no listener) so a send to it spools.
fn offline_peer(id: &str) -> PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).unwrap();
    let rec = spt_store::info::InfoJson::new(id, "now", std::process::id(), "peer-sess", "ready_agent");
    spt_store::info::write_info(&perch_path, &rec).unwrap();
    perch_path
}

/// Run `spt send <target> --from <from> --user-msg` with `OWL_SESSION_ID` set
/// (so the gate's session-proven origin resolves), piping `body` on stdin.
fn send_user_msg(
    spt: &Path,
    home: &Path,
    target: &str,
    from: &str,
    session_id: &str,
    body: &str,
) -> bool {
    let mut child = Command::new(spt)
        .no_window()
        .args(["send", target, "--from", from, "--user-msg"])
        .env("SPT_HOME", home)
        .env("OWL_SESSION_ID", session_id)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spt send");
    child
        .stdin
        .take()
        .unwrap()
        .write_all(body.as_bytes())
        .unwrap();
    child.wait_with_output().expect("send output").status.success()
}

/// The single spooled body for `perch_path` (oldest), or panic.
fn spooled_body(perch_path: &Path) -> String {
    let pending = spool::peek_all_at(perch_path).expect("peek");
    pending
        .first()
        .map(|(_, _, body, _)| body.clone())
        .expect("a spooled row")
}

#[test]
fn gateway_binds_and_is_the_user_backed_origin() {
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // Load the mock-gateway manifest; point [session.self] at the built binaries.
    let manifest_path = concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../adapters/mock-gateway/manifest.toml"
    );
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock-gateway manifest"),
    )
    .expect("mock-gateway manifest valid");
    assert_eq!(
        manifest.adapter.hostable_types,
        vec!["Gateway"],
        "the gateway adapter declares the Gateway hostable type"
    );
    manifest.session.self_.as_mut().unwrap().command = format!(
        "\"{}\" --id {{id}} --session-id {{session_id}} --type gateway --mock-adapter mock-gateway --spt \"{}\"",
        mock_bin.display(),
        spt_bin.display(),
    );

    // 1. Spawn the gateway session (bounded) → it fires `api bind --type gateway`.
    let gw = "playdate-gw";
    let gw_session = "sid-gw-1";
    let runtime = ManifestRuntime::new(manifest);
    let out = runtime
        .run_bounded(
            "self",
            &keys(&[("id", gw), ("session_id", gw_session)]),
            Duration::from_secs(30),
        )
        .expect("spawn mock gateway");
    assert!(
        out.success(),
        "mock-gateway session failed: code={:?}\nstdout={}\nstderr={}",
        out.status_code,
        out.stdout,
        out.stderr
    );

    // The perch bound as a GATEWAY (the un-hardcode: NOT forced to live_agent).
    let gw_perch = perch::resolve_perch_path(gw, ParentHint::Infer);
    let rec = spt_store::info::read_info(&gw_perch).expect("gateway info.json after bind");
    assert_eq!(rec.session_id, gw_session);
    assert_eq!(rec.state, "gateway", "a Gateway binds with its open-type tag");

    // 2. A user-msg SENT FROM the gateway is HONORED (the user-backed origin):
    //    the peer's spool carries a verbatim user-msg envelope.
    let peer_u = "peer-of-gateway";
    let peer_u_perch = offline_peer(peer_u);
    assert!(
        send_user_msg(&spt_bin, home.path(), peer_u, gw, gw_session, "ship it now"),
        "send from the gateway succeeds"
    );
    let body = spooled_body(&peer_u_perch);
    let p = spt_proto::event::parse_event(&body)
        .expect("the gateway's user-msg spooled as a typed envelope");
    assert_eq!(
        p.event_type.as_deref(),
        Some("user-msg"),
        "Gateway origin → user-msg HONORED, got body: {body:?}"
    );
    assert_eq!(p.body, "ship it now");

    // 3. The SAME --user-msg request from a live_agent session is RE-STAMPED.
    //    Bind an ordinary agent perch and send as it.
    let agent = "ordinary-agent";
    let agent_session = "sid-agent-1";
    let bind = Command::new(&spt_bin)
        .no_window()
        .args([
            "api",
            "--adapter",
            "mock",
            "bind",
            agent,
            "--set-session-id",
            agent_session,
        ])
        .env("SPT_HOME", home.path())
        .status()
        .expect("bind agent");
    assert!(bind.success(), "agent bind");
    let agent_perch = perch::resolve_perch_path(agent, ParentHint::Infer);
    assert_eq!(
        spt_store::info::read_info(&agent_perch).unwrap().state,
        "live_agent"
    );

    let peer_a = "peer-of-agent";
    let peer_a_perch = offline_peer(peer_a);
    assert!(
        send_user_msg(&spt_bin, home.path(), peer_a, agent, agent_session, "do this"),
        "send from the agent succeeds (degraded, never rejected)"
    );
    let body = spooled_body(&peer_a_perch);
    // Re-stamped: the body is the plain text, NOT a user-msg envelope. (It
    // renders as type="msg" at the listener surface — the re-stamp.)
    assert!(
        spt_proto::event::parse_event(&body)
            .and_then(|p| p.event_type)
            .as_deref()
            != Some("user-msg"),
        "agent-family origin → user-msg RE-STAMPED (not a user-msg envelope), got: {body:?}"
    );
    assert_eq!(body, "do this", "the plain body rides as msg");
}


Read resolution layer (shellhost) for unit test + ownership paths.


<owl_messages>
<owl_message from="todlando-psyche" priority="normal"><EVENT type="echo_commune" from="todlando-psyche" timestamp="2026-06-15T22:26:42-07:00" machine="HFENDULEAM" project="spt-core" branch="m11-shell-substrate" head_sha="9ebb1c60eafd6816c8525c991730c161784e1913" head_subject="docs: log attach_survives timeout to the FLAKE-LEDGER (#7)" note="Echo commune brief — auto-fired">&lt;live-context&gt;
Generation 46. Pulse 2026-06-16T05:23:28Z. Head 9ebb1c6. **W4 DESIGN-GATE = GO. Checkpointed, ready to build on fresh context.**

## W3: CLOSED
doyle GATE PASS. CI 27595180782. 8 commits @221c0e7 on PR#16. REQ-SHELL-4 [doc,impl,unit,int].
FLAKE-LEDGER #7 @9ebb1c6 local (unpushed — rides W4's first push).

## W4: DESIGN-GATED, BUILD PENDING
REQ-SHELL-5 (Gateway-as-owner capstone). PROOF-NOT-CHANGE — T0 audit found NO type-gate on owner type. Expect ZERO production diff. If E2E surfaces a real type-gate, fix it loud, do NOT paper over.

**doyle gate answers:**
- Q1: same-node only (cross-node = W5 [twohost], deferred)
- Q2: distinct UNIT + int E2E (E2E = int, not unit; traceable needs both)
- Q3: new file `crates/spt/tests/gateway_owner_shell_e2e.rs`
- REFINEMENT (gate-blocking): exclusivity NEGATIVE must be SAME-TYPE, DIFFERENT-ID (gateway-B refused, not just different-type). Proves key = endpoint_id, not type.

**T4.2 int E2E plan:**
- POSITIVE: gateway-A owns mock shell, succeeds on ALL paths same-node: spawn → bind-shell(online) → cmd(durable) → drive(latest-wins) → TUNNEL(open + opaque byte round-trip) → link(relink)
- NEGATIVE: gateway-B (same type, different id) REFUSED on cmd/drive/tunnel resolve
- OPTIONAL: REQ-CONSENT-3 act-gate exercised under gateway-A — grant keys on owner endpoint_id

**Unit:** gateway-typed owner resolves identically at resolution layer (find_shell_by_token / resolve_link_target) with no type inspection.

**Harness:** reuse tunnel_e2e.rs `start_inproc_daemon` (needs broker.net() for tunnel path). Mock-gateway construction: gateway_e2e.rs InfoJson endpoint_type=&quot;gateway&quot; pattern.

## W5 Carries (M11-PLAN T5.x)
drive [twohost] rung · tunnel [twohost] rung · how-to subnet topic (REQ-DOCS-6) · ADR-0020 tunnel EVENT-exemption enumeration · on-LAN posture docs

## Pre-flight (every commit)
clippy --workspace --all-targets -D warnings + traceable EXIT 0 + xtask check; CLI add → xtask gen + no milestone/REQ codes in clap help
&lt;/live-context&gt;
&lt;project-context&gt;
CURRENT_PROJECT_CONTEXT (name=spt-core):
M11-W4 GATED BUILD PLAN (REQ-SHELL-5, last build wave before W5 rig+docs):

PREMISE = proof-not-change. T0 audit found NO ownership path gates on owner TYPE (is_agent_endpoint / EndpointFamily::Agent serve only the user-msg gate + taxonomy); REQ-SHELL-5 [doc,impl] green from T0. W4 proves it end-to-end + activates unit/int. EXPECT ZERO production diff. If the E2E surfaces a real type-gate on ANY path (spawn/bind/cmd/drive/tunnel/relink), that flips that path from proof to change — surface LOUD + fix, do NOT paper over.

T4.2 int E2E (`crates/spt/tests/gateway_owner_shell_e2e.rs`):
- POSITIVE: gateway-A — a GATEWAY-typed owner (InfoJson endpoint_type=&quot;gateway&quot;, the gateway_e2e.rs mock-gateway pattern) — owns a mock shell and succeeds on EVERY ownership path, same-node: spawn → bind-shell(online) → cmd(durable) → drive(latest-wins) → TUNNEL(open + opaque byte round-trip, the W3 surface) → link(relink). Proves no type-gate blocks a non-agent owner.
- NEGATIVE: gateway-B (SAME type=&quot;gateway&quot;, DIFFERENT endpoint_id) is REFUSED on the control paths (cmd/drive/tunnel resolve under B → NO_SHELL/refused). Proves control-exclusivity keys on owner endpoint_id, NOT type.
- OPTIONAL (doyle, worth it): the mock shell declares a REQ-CONSENT-3 per-capability act-gate; exercise one gated cmd under gateway-A and confirm the grant keys on the owner endpoint id (same id-not-type invariant on the consent surface).

UNIT (distinct from the E2E): a gateway-typed owner resolves/owns identically at the resolution layer — find_shell_by_token / resolve_link_target with a gateway-typed owner returns the owner-id opaquely with no type inspection.

ACTIVATE REQ-SHELL-5 = [doc, impl, unit, int].

Harness to reuse: tunnel_e2e.rs `start_inproc_daemon` (seed + broker-with-hermetic-NetHost + tunnel-control) — the tunnel path needs broker.net(), so W4's E2E needs the SAME broker-with-net harness, not the lighter drive_e2e one. Mock-gateway owner construction: copy the InfoJson endpoint_type=&quot;gateway&quot; setup from gateway_e2e.rs (read it first). Pre-flight every commit: clippy --workspace --all-targets -D warnings + traceable EXIT 0 + xtask check. After W4: W5 carries (5) = drive [twohost] + tunnel [twohost] rungs + how-to subnet topic + ADR-0020 tunnel &lt;EVENT&gt;-exemption enumeration + on-LAN posture docs (all logged M11-PLAN T5.x). Detail in memory [[m11-w3-progress]].
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


crates\spt-daemon\src\linkhost.rs:190:pub fn resolve_link_target(
crates\spt-daemon\src\linkhost.rs:269:pub fn drive_shell(
crates\spt-daemon\src\linkhost.rs:410:pub fn relink_shell(
crates\spt-daemon\src\shellhost.rs:55:pub fn read_link_token(shell_perch: &Path) -> Option<String> {
crates\spt-daemon\src\shellhost.rs:256:pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
crates\spt-daemon\src\shellhost.rs:370:pub fn close_shell(


Echo commune = FYI. Read resolution functions + ownership-path internals.


40	/// runtime material, beside `info.json` — the perch *record* contract in
41	/// STORAGE.md constrains `info.json`'s fields, not the perch's files).
42	pub const LINK_TOKEN_FILE: &str = "link.token";
43	/// The launched binary's OS pid (teardown's kill handle until the D4
44	/// lifecycle owns close semantics).
45	pub const SHELL_PID_FILE: &str = "shell.pid";
46
47	/// Mint a fresh link token: 32 hex chars of crypto-grade randomness (the
48	/// `api.token` recipe — spt-proto's Ed25519 seed generator, no extra RNG dep).
49	pub fn mint_link_token() -> String {
50	    let seed = spt_proto::identity::Identity::generate().seed();
51	    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
52	}
53
54	/// Read a shell perch's parked link token.
55	pub fn read_link_token(shell_perch: &Path) -> Option<String> {
56	    std::fs::read_to_string(shell_perch.join(LINK_TOKEN_FILE))
57	        .ok()
58	        .map(|s| s.trim().to_string())
59	        .filter(|s| !s.is_empty())
60	}
61
62	/// The per-link channel key, derived from the minted token: `SHA-256(token)`.
63	/// Both ends hold the token (spt parked it; the binary got it in argv), so
64	/// both derive the same key without a second exchange.
65	// [impl->REQ-SHELL-1]
66	pub fn link_key(token: &str) -> [u8; 32] {
67	    let mut h = Sha256::new();
68	    h.update(token.as_bytes());
69	    h.finalize().into()
70	}
71
72	/// Authenticate one link frame: `HMAC-SHA256(link_key, frame)`, hex. Required
73	/// on every message both directions (CONTEXT §local-link authentication) —
74	/// the D3c channels stamp + verify this per frame; a frame without a valid
75	/// MAC is dropped, never processed.
76	// [impl->REQ-SHELL-1]
77	pub fn frame_mac(key: &[u8; 32], frame: &[u8]) -> String {
78	    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
79	    mac.update(frame);
80	    mac.finalize()
81	        .into_bytes()
82	        .iter()
83	        .map(|b| format!("{b:02x}"))
84	        .collect()
85	}
86
87	/// Verify a frame's MAC (constant-time inner compare via the hmac crate).
88	pub fn verify_frame_mac(key: &[u8; 32], frame: &[u8], mac_hex: &str) -> bool {
89	    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
90	    mac.update(frame);
91	    let Ok(bytes) = hex_decode(mac_hex) else {
92	        return false;
93	    };
94	    mac.verify_slice(&bytes).is_ok()
95	}
96
97	fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
98	    if !s.len().is_multiple_of(2) {
99	        return Err(());
100	    }
101	    (0..s.len())
102	        .step_by(2)
103	        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
104	        .collect()
105	}
106
107	/// Fill the manifest's opaque `spawn` template for one instance (mints + parks
108	/// the link token first — the binary may bind immediately) and tokenize it.
109	/// Shared by the detached and broker-hosted launch paths.
110	fn fill_spawn_command(
111	    owlery: &Path,
112	    owner: &str,
113	    shell_id: &str,
114	    adapter_name: &str,
115	    shell: &Shell,
116	) -> Result<Vec<String>, String> {
117	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
118	    // Mutual exclusivity, the online side (M5-D4b): the watcher dies before
119	    // the binary rises — and any (re)launch is "shell activity", so the
120	    // crash give-up latch re-arms. [impl->REQ-SHELL-2]
121	    crate::shellwake::kill_waker_at(&perch);
122	    crate::shellwake::clear_gave_up(&perch);
123	    let token = mint_link_token();
124	    // Park the token BEFORE the spawn: the binary may bind immediately.
125	    std::fs::write(perch.join(LINK_TOKEN_FILE), &token).map_err(|e| format!("park token: {e}"))?;
126
127	    let keys = BTreeMap::from([
128	        ("id".to_string(), shell_id.to_string()),
129	        ("adapter_name".to_string(), adapter_name.to_string()),
130	        ("link_token".to_string(), token),
131	    ]);
132	    let filled =
133	        spt_runtime::runtime::fill_template(&shell.spawn, &keys).map_err(|e| e.to_string())?;
134	    let tokens = spt_runtime::runtime::tokenize(&filled);
135	    if tokens.is_empty() {
136	        return Err("empty spawn command".into());
137	    }
138	    Ok(tokens)
139	}
140
141	/// Launch a shell instance's binary per the manifest's `command_receipt`
142	/// (M5-D3c): **relay** (default) spawns detached (stdio discarded — the
143	/// binary drains via `api poll --link`); **stdin** spawns broker-hosted so
144	/// the daemon owns the stdin pipe ([`launch_shell_brokered_in`] — command
145	/// frames ride the broker's `send_effect` input seam); **http** refuses at
146	/// spawn, explicitly — an unimplemented mode is never a silent fallback.
147	/// Records the pid; returns it. The perch stays **offline** until the
148	/// binary's `api bind-shell` handshake onlines it — launch is not the online
149	/// switch any more than spawn is.
150	// [impl->REQ-SHELL-1]
151	pub fn launch_shell(
152	    owlery: &Path,
153	    owner: &str,
154	    shell_id: &str,
155	    adapter_name: &str,
156	    shell: &Shell,
157	) -> Result<u32, String> {
158	    match shell.command_receipt {
159	        Some(CommandReceipt::Http) => Err(

/// runtime material, beside `info.json` — the perch *record* contract in
/// STORAGE.md constrains `info.json`'s fields, not the perch's files).
pub const LINK_TOKEN_FILE: &str = "link.token";
/// The launched binary's OS pid (teardown's kill handle until the D4
/// lifecycle owns close semantics).
pub const SHELL_PID_FILE: &str = "shell.pid";

/// Mint a fresh link token: 32 hex chars of crypto-grade randomness (the
/// `api.token` recipe — spt-proto's Ed25519 seed generator, no extra RNG dep).
pub fn mint_link_token() -> String {
    let seed = spt_proto::identity::Identity::generate().seed();
    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
}

/// Read a shell perch's parked link token.
pub fn read_link_token(shell_perch: &Path) -> Option<String> {
    std::fs::read_to_string(shell_perch.join(LINK_TOKEN_FILE))
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// The per-link channel key, derived from the minted token: `SHA-256(token)`.
/// Both ends hold the token (spt parked it; the binary got it in argv), so
/// both derive the same key without a second exchange.
// [impl->REQ-SHELL-1]
pub fn link_key(token: &str) -> [u8; 32] {
    let mut h = Sha256::new();
    h.update(token.as_bytes());
    h.finalize().into()
}

/// Authenticate one link frame: `HMAC-SHA256(link_key, frame)`, hex. Required
/// on every message both directions (CONTEXT §local-link authentication) —
/// the D3c channels stamp + verify this per frame; a frame without a valid
/// MAC is dropped, never processed.
// [impl->REQ-SHELL-1]
pub fn frame_mac(key: &[u8; 32], frame: &[u8]) -> String {
    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
    mac.update(frame);
    mac.finalize()
        .into_bytes()
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

/// Verify a frame's MAC (constant-time inner compare via the hmac crate).
pub fn verify_frame_mac(key: &[u8; 32], frame: &[u8], mac_hex: &str) -> bool {
    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
    mac.update(frame);
    let Ok(bytes) = hex_decode(mac_hex) else {
        return false;
    };
    mac.verify_slice(&bytes).is_ok()
}

fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
    if !s.len().is_multiple_of(2) {
        return Err(());
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
        .collect()
}

/// Fill the manifest's opaque `spawn` template for one instance (mints + parks
/// the link token first — the binary may bind immediately) and tokenize it.
/// Shared by the detached and broker-hosted launch paths.
fn fill_spawn_command(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    adapter_name: &str,
    shell: &Shell,
) -> Result<Vec<String>, String> {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    // Mutual exclusivity, the online side (M5-D4b): the watcher dies before
    // the binary rises — and any (re)launch is "shell activity", so the
    // crash give-up latch re-arms. [impl->REQ-SHELL-2]
    crate::shellwake::kill_waker_at(&perch);
    crate::shellwake::clear_gave_up(&perch);
    let token = mint_link_token();
    // Park the token BEFORE the spawn: the binary may bind immediately.
    std::fs::write(perch.join(LINK_TOKEN_FILE), &token).map_err(|e| format!("park token: {e}"))?;

    let keys = BTreeMap::from([
        ("id".to_string(), shell_id.to_string()),
        ("adapter_name".to_string(), adapter_name.to_string()),
        ("link_token".to_string(), token),
    ]);
    let filled =
        spt_runtime::runtime::fill_template(&shell.spawn, &keys).map_err(|e| e.to_string())?;
    let tokens = spt_runtime::runtime::tokenize(&filled);
    if tokens.is_empty() {
        return Err("empty spawn command".into());
    }
    Ok(tokens)
}

/// Launch a shell instance's binary per the manifest's `command_receipt`
/// (M5-D3c): **relay** (default) spawns detached (stdio discarded — the
/// binary drains via `api poll --link`); **stdin** spawns broker-hosted so
/// the daemon owns the stdin pipe ([`launch_shell_brokered_in`] — command
/// frames ride the broker's `send_effect` input seam); **http** refuses at
/// spawn, explicitly — an unimplemented mode is never a silent fallback.
/// Records the pid; returns it. The perch stays **offline** until the
/// binary's `api bind-shell` handshake onlines it — launch is not the online
/// switch any more than spawn is.
// [impl->REQ-SHELL-1]
pub fn launch_shell(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    adapter_name: &str,
    shell: &Shell,
) -> Result<u32, String> {
    match shell.command_receipt {
        Some(CommandReceipt::Http) => Err(


240	        .map_err(|e| format!("record pid: {e}"))?;
241	    Ok(pid)
242	}
243
244	/// Resolve a shell instance by its link token alone — the "owner from the
245	/// link" lookup every shell-flavored machinery call shares. Scans every
246	/// owner's `shells/` namespace for the perch parking exactly this token; an
247	/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
248	/// job ([`bind_shell_by_token`]), not the lookup's.
249	///
250	/// Owner-type-agnostic: `owner` is an opaque endpoint-id namespace string here
251	/// and through every shell-machinery call — no path inspects the owner's
252	/// endpoint type, so a Gateway (or any non-Shell type) owns identically to an
253	/// agent (CONTEXT §Shell model "Owner-linked, exclusive"; M11-T0 audit).
254	// [impl->REQ-SHELL-1]
255	// [impl->REQ-SHELL-5]
256	pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
257	    if token.is_empty() {
258	        return None;
259	    }
260	    for owner in spt_store::perch::list_self_perch_ids(owlery) {
261	        for (shell_id, _info) in shellinfo::list_shells(owlery, &owner) {
262	            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
263	            if read_link_token(&perch).as_deref() == Some(token) {
264	                return Some((owner, shell_id));
265	            }
266	        }
267	    }
268	    None
269	}
270
271	/// Resolve a bind by token alone and flip the instance online — the
272	/// handshake's receiving end. The spawn template carries only
273	/// `{link_token}`, so the binary never has to know its owner ("owner from
274	/// the link"). An unknown/empty token binds nothing. Returns
275	/// `(owner, shell_id)`.
276	// [impl->REQ-SHELL-1]
277	pub fn bind_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
278	    let (owner, shell_id) = find_shell_by_token(owlery, token)?;
279	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
280	    let mut info = shellinfo::read_shell_info(&perch)?;
281	    info.status = SHELL_STATUS_ONLINE.to_string();
282	    shellinfo::write_shell_info(&perch, &info).ok()?;
283	    Some((owner, shell_id))
284	}
285
286	/// Verify a machinery call's link credential against one instance — the
287	/// per-message auth gate the D3c channel surfaces (`api emit`, `api poll`)
288	/// sit behind.
289	// [impl->REQ-SHELL-1]
290	pub fn verify_link(owlery: &Path, owner: &str, shell_id: &str, token: &str) -> bool {
291	    if token.is_empty() {
292	        return false;
293	    }
294	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
295	    read_link_token(&perch).as_deref() == Some(token)
296	}
297
298	/// Best-effort kill of a launched shell binary (teardown's interim close —
299	/// the D4 lifecycle adds the manifest pre-close instruction + termination
300	/// timeout in front of this). Bounded; never blocks on a wedged child.
301	pub fn kill_shell_pid(pid: u32) {
302	    if pid == 0 {
303	        // A broker-hosted spawn whose backend exposed no pid records 0 —
304	        // never kill that (unix `kill 0` would signal the whole group).
305	        return;
306	    }
307	    #[cfg(windows)]
308	    {
309	        // taskkill is a console child of the console-less daemon: spawn it
310	        // windowless or teardown flashes a blank conhost (KNOWN-HAZARDS 5.8).
311	        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
312	        use std::os::windows::process::CommandExt;
313	        let _ = Command::new("taskkill")
314	            .args(["/PID", &pid.to_string(), "/T", "/F"])
315	            .stdout(Stdio::null())
316	            .stderr(Stdio::null())
317	            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
318	            .status();
319	    }
320	    #[cfg(unix)]
321	    let _ = Command::new("kill")
322	        .args(["-9", &pid.to_string()])
323	        .stdout(Stdio::null())
324	        .stderr(Stdio::null())
325	        .status();
326	}
327
328	/// Teardown's process half: read the recorded pid (if any) and kill it. The
329	/// caller removes the perch dir after.
330	pub fn kill_shell_at(shell_perch: &Path) {
331	    if let Ok(s) = std::fs::read_to_string(shell_perch.join(SHELL_PID_FILE)) {
332	        if let Ok(pid) = s.trim().parse::<u32>() {
333	            kill_shell_pid(pid);
334	        }
335	    }
336	}
337
338	/// What [`close_shell`] did with the perch after the binary closed — the
339	/// ephemeral-vs-persistent divergence (CONTEXT §Shell model lifecycle).
340	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341	pub enum CloseOutcome {
342	    /// `ephemeral = true`: perch + spool history erased, mint slot freed.
343	    TornDown,
344	    /// Everything else: perch kept, status=offline, re-linkable.
345	    Offline,
346	}
347
348	/// The link-break close (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell model
349	/// lifecycle: "a link-break **always** closes the broker + shell binary; the
350	/// binary never survives"):
351	///
352	/// 1. **pre-close instruction** (manifest `pre_close`, optional): composed as
353	///    a [`shell_close` frame](crate::shellchan::compose_close_frame), stamped
354	///    with the link key, and delivered per the receipt mode — relay: spooled
355	///    (the binary may drain it during the grace window); stdin: spooled +
356	///    best-effort broker push. Skipped when nothing is running.
357	/// 2. **termination window** (`close_timeout_ms`, absent ⇒ 0): poll the
358	///    recorded pid for a graceful exit up to the window.
359	/// 3. **force-close**: unconditional kill at window end; the pid file and the
360	///    **link token die with the link** (a stale token must never drive a
361	///    relinked instance — relink mints fresh).
362	/// 4. **divergence**: `ephemeral` ⇒ full teardown + history erase (the spool
363	///    lives in the perch dir — removal IS the erase); otherwise the perch
364	///    stays, status=offline, re-linkable.
365	///
366	/// `shell = None` (adapter deregistered after spawn) closes with defaults: no
367	/// pre-close, no grace, **non-ephemeral** — history is never erased without
368	/// the manifest's authority.
369	// [impl->REQ-SHELL-2]
370	pub fn close_shell(
371	    owlery: &Path,
372	    owner: &str,
373	    shell_id: &str,
374	    shell: Option<&Shell>,
375	) -> Result<CloseOutcome, String> {
376	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
377	    let pid = std::fs::read_to_string(perch.join(SHELL_PID_FILE))
378	        .ok()
379	        .and_then(|s| s.trim().parse::<u32>().ok())
380	        .filter(|&p| p != 0);
381	    // Reap first: an in-process spawner's exited child is a zombie until
382	    // somebody waits — without this the liveness probe would hold a dead
383	    // binary "running" through the whole grace window.
384	    if let Some(p) = pid {
385	        spt_store::proc::reap_if_child(p);
386	    }
387	    let running = pid.is_some_and(spt_store::proc::is_process_alive);
388
389	    // 1. The pre-close instruction — only meaningful while something runs.
390	    if running {
391	        if let Some(instruction) = shell.and_then(|s| s.pre_close.as_deref()) {
392	            if let Some(token) = read_link_token(&perch) {
393	                let key = link_key(&token);
394	                let frame = crate::shellchan::compose_close_frame(owner, instruction);
395	                let stamped = crate::shellchan::stamp_frame(&key, &frame);
396	                // Best-effort by design: a wedged spool/broker must never
397	                // block the close — the force-kill below is the guarantee.
398	                let _ = crate::shellchan::spool_shell_frame(owlery, owner, shell_id, &stamped);
399	                if shell.map(|s| s.command_receipt) == Some(Some(CommandReceipt::Stdin)) {

        .map_err(|e| format!("record pid: {e}"))?;
    Ok(pid)
}

/// Resolve a shell instance by its link token alone — the "owner from the
/// link" lookup every shell-flavored machinery call shares. Scans every
/// owner's `shells/` namespace for the perch parking exactly this token; an
/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
///
/// Owner-type-agnostic: `owner` is an opaque endpoint-id namespace string here
/// and through every shell-machinery call — no path inspects the owner's
/// endpoint type, so a Gateway (or any non-Shell type) owns identically to an
/// agent (CONTEXT §Shell model "Owner-linked, exclusive"; M11-T0 audit).
// [impl->REQ-SHELL-1]
// [impl->REQ-SHELL-5]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
    if token.is_empty() {
        return None;
    }
    for owner in spt_store::perch::list_self_perch_ids(owlery) {
        for (shell_id, _info) in shellinfo::list_shells(owlery, &owner) {
            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
            if read_link_token(&perch).as_deref() == Some(token) {
                return Some((owner, shell_id));
            }
        }
    }
    None
}

/// Resolve a bind by token alone and flip the instance online — the
/// handshake's receiving end. The spawn template carries only
/// `{link_token}`, so the binary never has to know its owner ("owner from
/// the link"). An unknown/empty token binds nothing. Returns
/// `(owner, shell_id)`.
// [impl->REQ-SHELL-1]
pub fn bind_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
    let (owner, shell_id) = find_shell_by_token(owlery, token)?;
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
    let mut info = shellinfo::read_shell_info(&perch)?;
    info.status = SHELL_STATUS_ONLINE.to_string();
    shellinfo::write_shell_info(&perch, &info).ok()?;
    Some((owner, shell_id))
}

/// Verify a machinery call's link credential against one instance — the
/// per-message auth gate the D3c channel surfaces (`api emit`, `api poll`)
/// sit behind.
// [impl->REQ-SHELL-1]
pub fn verify_link(owlery: &Path, owner: &str, shell_id: &str, token: &str) -> bool {
    if token.is_empty() {
        return false;
    }
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    read_link_token(&perch).as_deref() == Some(token)
}

/// Best-effort kill of a launched shell binary (teardown's interim close —
/// the D4 lifecycle adds the manifest pre-close instruction + termination
/// timeout in front of this). Bounded; never blocks on a wedged child.
pub fn kill_shell_pid(pid: u32) {
    if pid == 0 {
        // A broker-hosted spawn whose backend exposed no pid records 0 —
        // never kill that (unix `kill 0` would signal the whole group).
        return;
    }
    #[cfg(windows)]
    {
        // taskkill is a console child of the console-less daemon: spawn it
        // windowless or teardown flashes a blank conhost (KNOWN-HAZARDS 5.8).
        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
        use std::os::windows::process::CommandExt;
        let _ = Command::new("taskkill")
            .args(["/PID", &pid.to_string(), "/T", "/F"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
            .status();
    }
    #[cfg(unix)]
    let _ = Command::new("kill")
        .args(["-9", &pid.to_string()])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

/// Teardown's process half: read the recorded pid (if any) and kill it. The
/// caller removes the perch dir after.
pub fn kill_shell_at(shell_perch: &Path) {
    if let Ok(s) = std::fs::read_to_string(shell_perch.join(SHELL_PID_FILE)) {
        if let Ok(pid) = s.trim().parse::<u32>() {
            kill_shell_pid(pid);
        }
    }
}

/// What [`close_shell`] did with the perch after the binary closed — the
/// ephemeral-vs-persistent divergence (CONTEXT §Shell model lifecycle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseOutcome {
    /// `ephemeral = true`: perch + spool history erased, mint slot freed.
    TornDown,
    /// Everything else: perch kept, status=offline, re-linkable.
    Offline,
}

/// The link-break close (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell model
/// lifecycle: "a link-break **always** closes the broker + shell binary; the
/// binary never survives"):
///
/// 1. **pre-close instruction** (manifest `pre_close`, optional): composed as
///    a [`shell_close` frame](crate::shellchan::compose_close_frame), stamped
///    with the link key, and delivered per the receipt mode — relay: spooled
///    (the binary may drain it during the grace window); stdin: spooled +
///    best-effort broker push. Skipped when nothing is running.
/// 2. **termination window** (`close_timeout_ms`, absent ⇒ 0): poll the
///    recorded pid for a graceful exit up to the window.
/// 3. **force-close**: unconditional kill at window end; the pid file and the
///    **link token die with the link** (a stale token must never drive a
///    relinked instance — relink mints fresh).
/// 4. **divergence**: `ephemeral` ⇒ full teardown + history erase (the spool
///    lives in the perch dir — removal IS the erase); otherwise the perch
///    stays, status=offline, re-linkable.
///
/// `shell = None` (adapter deregistered after spawn) closes with defaults: no
/// pre-close, no grace, **non-ephemeral** — history is never erased without
/// the manifest's authority.
// [impl->REQ-SHELL-2]
pub fn close_shell(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    shell: Option<&Shell>,
) -> Result<CloseOutcome, String> {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let pid = std::fs::read_to_string(perch.join(SHELL_PID_FILE))
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
        .filter(|&p| p != 0);
    // Reap first: an in-process spawner's exited child is a zombie until
    // somebody waits — without this the liveness probe would hold a dead
    // binary "running" through the whole grace window.
    if let Some(p) = pid {
        spt_store::proc::reap_if_child(p);
    }
    let running = pid.is_some_and(spt_store::proc::is_process_alive);

    // 1. The pre-close instruction — only meaningful while something runs.
    if running {
        if let Some(instruction) = shell.and_then(|s| s.pre_close.as_deref()) {
            if let Some(token) = read_link_token(&perch) {
                let key = link_key(&token);
                let frame = crate::shellchan::compose_close_frame(owner, instruction);
                let stamped = crate::shellchan::stamp_frame(&key, &frame);
                // Best-effort by design: a wedged spool/broker must never
                // block the close — the force-kill below is the guarantee.
                let _ = crate::shellchan::spool_shell_frame(owlery, owner, shell_id, &stamped);
                if shell.map(|s| s.command_receipt) == Some(Some(CommandReceipt::Stdin)) {


Read linkhost resolve_link_target + drive_shell (negative-test keying).


180	    let store = GrantStore::load();
181	    let target = crate::consent::most_recently_active(owlery);
182	    act_gate_decide(&store, shell, op, owner, &node, target)
183	}
184
185	/// Resolve a shell ref into everything a link action needs: the canonical id,
186	/// the perch record, and the registered adapter's `[shell]` section
187	/// (capabilities resolve from the manifest by `adapter_name`, never the
188	/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
189	// [impl->REQ-SHELL-1]
190	pub fn resolve_link_target(
191	    owlery: &Path,
192	    owner: &str,
193	    shell_ref: &str,
194	) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
195	    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
196	        return Err(ShellLinkError::NoShell);
197	    };
198	    // Capabilities resolve from the merged view: the stored adapter_name is the
199	    // composite option, so a profiled shell links against its overlaid [shell].
200	    let adapters_dir = spt_store::perch::adapters_dir();
201	    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
202	        .ok()
203	        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
204	        .map(|(_, m)| m);
205	    let Some(shell) = manifest.and_then(|m| m.shell) else {
206	        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
207	    };
208	    Ok((id, info, shell))
209	}
210
211	/// At shell-online (the `api bind-shell` seam), open the shell's opaque tunnel if
212	/// its manifest enables it (M11-W3, REQ-SHELL-4). Resolves the shell's `[shell]`
213	/// body and, when `[shell.tunnel].enable`, ensures the broker-held loopback stream
214	/// pair under the CURRENT link token — so the tunnel's lifetime is the link's
215	/// (a relink mints a fresh token ⇒ a fresh pair; the stale entry is inert by stamp,
216	/// the crash-proof R1 leg). Best-effort: `Ok(false)` when the shell declares no
217	/// tunnel or no link token is parked; `Err` only on a live ensure failure.
218	// [impl->REQ-SHELL-4]
219	pub fn ensure_shell_tunnel(owlery: &Path, owner: &str, shell_id: &str) -> io::Result<bool> {
220	    let Ok((id, _info, shell)) = resolve_link_target(owlery, owner, shell_id) else {
221	        return Ok(false); // unresolvable manifest ⇒ no tunnel
222	    };
223	    if !shell.tunnel.enable {
224	        return Ok(false);
225	    }
226	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
227	    let Some(token) = shellhost::read_link_token(&perch) else {
228	        return Ok(false); // no parked link token ⇒ nothing to stamp
229	    };
230	    match crate::tunnelhub::tunnel_ensure(
231	        &crate::endpoint::tunnel_socket_name(),
232	        owner,
233	        &id,
234	        &token,
235	    )? {
236	        Ok(_) => Ok(true),
237	        Err(e) => Err(io::Error::other(e)),
238	    }
239	}
240
241	/// Resolve the OWNER-side tunnel stream id for `shell_ref` under `owner` (M11-W3,
242	/// REQ-SHELL-4): reads the parked link token and presents it to the registry so a
243	/// dead-generation stream is never surfaced (R1). `Ok(None)` ⇒ no live tunnel (the
244	/// shell is offline / declares none / the entry is a stale generation).
245	// [impl->REQ-SHELL-4]
246	pub fn tunnel_owner_stream(owlery: &Path, owner: &str, shell_ref: &str) -> io::Result<Option<u64>> {
247	    let Ok((id, _info, _shell)) = resolve_link_target(owlery, owner, shell_ref) else {
248	        return Ok(None);
249	    };
250	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
251	    let Some(token) = shellhost::read_link_token(&perch) else {
252	        return Ok(None);
253	    };
254	    crate::tunnelhub::tunnel_resolve(
255	        &crate::endpoint::tunnel_socket_name(),
256	        owner,
257	        &id,
258	        crate::tunnelhub::TunnelEnd::Owner,
259	        &token,
260	    )
261	}
262
263	/// Drive one vocabulary-checked typed command down the durable command
264	/// channel: vocab-check → compose → MAC-stamp under the parked token's link
265	/// key → spool on the shell perch. The shared core of the local
266	/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
267	/// the caller drains per the manifest receipt.
268	// [impl->REQ-SHELL-1]
269	pub fn drive_shell(
270	    owlery: &Path,
271	    owner: &str,
272	    shell_ref: &str,
273	    op: &str,
274	    args: &[String],
275	) -> Result<(String, Shell), ShellLinkError> {
276	    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
277	    if shell.command_receipt == Some(CommandReceipt::Http) {
278	        // Spawn already refuses http; this guards a manifest swapped after
279	        // spawn — an unimplemented mode never falls back silently.
280	        return Err(ShellLinkError::ReceiptUnsupported);
281	    }
282	    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
283	    let Some(token) = shellhost::read_link_token(&perch_path) else {
284	        return Err(ShellLinkError::NoLink);
285	    };
286	    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
287	    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
288	    // until granted — the command never lands in the durable channel on an
289	    // ungranted ask. Enforced here in the shared core so the cross-node serve
290	    // is gated identically to the local CLI (grants are target-node-local).
291	    // [impl->REQ-CONSENT-3]
292	    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
293	        return Err(ShellLinkError::NeedsApproval(ask, persist));
294	    }
295	    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
296	    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
297	    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
298	    Ok((id, shell))
299	}
300
301	/// The outcome of a drive-channel write (M11-W2, REQ-SHELL-3). Distinct from a
302	/// command spool ([`drive_shell`]): a drive is ephemeral control, so the only
303	/// outcomes are "written to the live slot" or "dropped because offline" — never
304	/// "queued for later".
305	#[derive(Debug, Clone, PartialEq, Eq)]
306	pub enum DriveDelivery {
307	    /// The frame was written to the shell's live drive slot (latest-wins).
308	    Driven { id: String, drive_type: String },
309	    /// The shell was offline — the frame was DROPPED. Drive is live-or-drop for
310	    /// BOTH ephemeral AND persistent instances: no spool, no queue, no wake, no
311	    /// replay (the divergence from the durable command core).
312	    DroppedOffline { id: String, drive_type: String },
313	}
314
315	/// The pure, spool-free preparation of a drive-channel write (M11-W2): resolve
316	/// the owner's instance (owner-scoped — a foreign owner is [`ShellLinkError::NoShell`]),
317	/// vocabulary-check the drive type against `[shell.drive]`, gate on liveness, and
318	/// (only when live) read the link token + compose the frame. **NO spool, NO
319	/// socket, NO wake — by construction**: this is the pure core where the no-spool
320	/// and drop-not-wake guarantees are provable without a running daemon.
321	#[derive(Debug)]
322	enum DrivePrep {
323	    /// The shell is live: write `frame` to its slot, stamped under `token`.
324	    Deliver {
325	        id: String,
326	        token: String,
327	        frame: String,
328	    },
329	    /// The shell is offline: drop (no token read, no frame composed).
330	    DroppedOffline { id: String },
331	}
332
333	// [impl->REQ-SHELL-3]
334	fn prepare_drive(
335	    owlery: &Path,
336	    owner: &str,
337	    shell_ref: &str,
338	    drive_type: &str,
339	    payload: &str,

    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    act_gate_decide(&store, shell, op, owner, &node, target)
}

/// Resolve a shell ref into everything a link action needs: the canonical id,
/// the perch record, and the registered adapter's `[shell]` section
/// (capabilities resolve from the manifest by `adapter_name`, never the
/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
// [impl->REQ-SHELL-1]
pub fn resolve_link_target(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
        return Err(ShellLinkError::NoShell);
    };
    // Capabilities resolve from the merged view: the stored adapter_name is the
    // composite option, so a profiled shell links against its overlaid [shell].
    let adapters_dir = spt_store::perch::adapters_dir();
    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
        .ok()
        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
        .map(|(_, m)| m);
    let Some(shell) = manifest.and_then(|m| m.shell) else {
        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
    };
    Ok((id, info, shell))
}

/// At shell-online (the `api bind-shell` seam), open the shell's opaque tunnel if
/// its manifest enables it (M11-W3, REQ-SHELL-4). Resolves the shell's `[shell]`
/// body and, when `[shell.tunnel].enable`, ensures the broker-held loopback stream
/// pair under the CURRENT link token — so the tunnel's lifetime is the link's
/// (a relink mints a fresh token ⇒ a fresh pair; the stale entry is inert by stamp,
/// the crash-proof R1 leg). Best-effort: `Ok(false)` when the shell declares no
/// tunnel or no link token is parked; `Err` only on a live ensure failure.
// [impl->REQ-SHELL-4]
pub fn ensure_shell_tunnel(owlery: &Path, owner: &str, shell_id: &str) -> io::Result<bool> {
    let Ok((id, _info, shell)) = resolve_link_target(owlery, owner, shell_id) else {
        return Ok(false); // unresolvable manifest ⇒ no tunnel
    };
    if !shell.tunnel.enable {
        return Ok(false);
    }
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
    let Some(token) = shellhost::read_link_token(&perch) else {
        return Ok(false); // no parked link token ⇒ nothing to stamp
    };
    match crate::tunnelhub::tunnel_ensure(
        &crate::endpoint::tunnel_socket_name(),
        owner,
        &id,
        &token,
    )? {
        Ok(_) => Ok(true),
        Err(e) => Err(io::Error::other(e)),
    }
}

/// Resolve the OWNER-side tunnel stream id for `shell_ref` under `owner` (M11-W3,
/// REQ-SHELL-4): reads the parked link token and presents it to the registry so a
/// dead-generation stream is never surfaced (R1). `Ok(None)` ⇒ no live tunnel (the
/// shell is offline / declares none / the entry is a stale generation).
// [impl->REQ-SHELL-4]
pub fn tunnel_owner_stream(owlery: &Path, owner: &str, shell_ref: &str) -> io::Result<Option<u64>> {
    let Ok((id, _info, _shell)) = resolve_link_target(owlery, owner, shell_ref) else {
        return Ok(None);
    };
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
    let Some(token) = shellhost::read_link_token(&perch) else {
        return Ok(None);
    };
    crate::tunnelhub::tunnel_resolve(
        &crate::endpoint::tunnel_socket_name(),
        owner,
        &id,
        crate::tunnelhub::TunnelEnd::Owner,
        &token,
    )
}

/// Drive one vocabulary-checked typed command down the durable command
/// channel: vocab-check → compose → MAC-stamp under the parked token's link
/// key → spool on the shell perch. The shared core of the local
/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
/// the caller drains per the manifest receipt.
// [impl->REQ-SHELL-1]
pub fn drive_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    op: &str,
    args: &[String],
) -> Result<(String, Shell), ShellLinkError> {
    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
    if shell.command_receipt == Some(CommandReceipt::Http) {
        // Spawn already refuses http; this guards a manifest swapped after
        // spawn — an unimplemented mode never falls back silently.
        return Err(ShellLinkError::ReceiptUnsupported);
    }
    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
    let Some(token) = shellhost::read_link_token(&perch_path) else {
        return Err(ShellLinkError::NoLink);
    };
    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
    // until granted — the command never lands in the durable channel on an
    // ungranted ask. Enforced here in the shared core so the cross-node serve
    // is gated identically to the local CLI (grants are target-node-local).
    // [impl->REQ-CONSENT-3]
    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask, persist));
    }
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))
}

/// The outcome of a drive-channel write (M11-W2, REQ-SHELL-3). Distinct from a
/// command spool ([`drive_shell`]): a drive is ephemeral control, so the only
/// outcomes are "written to the live slot" or "dropped because offline" — never
/// "queued for later".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriveDelivery {
    /// The frame was written to the shell's live drive slot (latest-wins).
    Driven { id: String, drive_type: String },
    /// The shell was offline — the frame was DROPPED. Drive is live-or-drop for
    /// BOTH ephemeral AND persistent instances: no spool, no queue, no wake, no
    /// replay (the divergence from the durable command core).
    DroppedOffline { id: String, drive_type: String },
}

/// The pure, spool-free preparation of a drive-channel write (M11-W2): resolve
/// the owner's instance (owner-scoped — a foreign owner is [`ShellLinkError::NoShell`]),
/// vocabulary-check the drive type against `[shell.drive]`, gate on liveness, and
/// (only when live) read the link token + compose the frame. **NO spool, NO
/// socket, NO wake — by construction**: this is the pure core where the no-spool
/// and drop-not-wake guarantees are provable without a running daemon.
#[derive(Debug)]
enum DrivePrep {
    /// The shell is live: write `frame` to its slot, stamped under `token`.
    Deliver {
        id: String,
        token: String,
        frame: String,
    },
    /// The shell is offline: drop (no token read, no frame composed).
    DroppedOffline { id: String },
}

// [impl->REQ-SHELL-3]
fn prepare_drive(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    drive_type: &str,
    payload: &str,


Read drive_e2e + actgate_e2e for cmd/drive/consent surface patterns.