diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 504603f..b41afd8 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -459,3 +459,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 新增真实临时 Workspace / Stronghold 测试,覆盖正常读写、CAS、同 ID 内容冲突、跨包隔离、重启重放、权限/伪造字段/私有路径拒绝、帧/正文/速率限制、硬链接与异 Vault 拒绝、凭据锁定撤销。另有 Workspace 故障注入测试在提交前拒绝,确认重启正文与 outbox 不变,同 ID 原内容可以重试并只增加一次 revision/outbox。 - 当前仍是内部策略适配器,未开放第三方执行。写入底层尚使用字符串路径,需完成全程句柄约束;原子撤权与最终提交的严格排序、真实管道身份认证、Vault 根目录替换、网络 broker 及完整攻击矩阵也尚未完成,不据此判定 C-02 / C-04 或整体生产化通过。 - 本轮完整 Rust desktop all-targets 回归 115 项通过、6 项 ignored;其中三个 Job 辅助入口和一个上传故障辅助入口由父测试实际驱动,另外两项是需显式执行的 60 秒期限验收与打包 Core 20 次冷启动,本轮未重跑。全目标 Clippy -D warnings 通过。日志 `.build/extension-file-broker-full-tests.log`、`.build/extension-file-broker-clippy.log`。未修改前端/Python 实现,本轮没有把此前测试结果冒充重新执行结果。 + + +## 增量:沙箱实例标准管道与实际文件 RPC 探针 + +- 新增 extension_stdio 的每次启动专用匿名管道;Host 端不继承,子端只有 stdin/stdout/stderr 三个句柄。扩展进程创建同时设置 SECURITY_CAPABILITIES 与 PROC_THREAD_ATTRIBUTE_HANDLE_LIST,STARTF_USESTDHANDLES 指向这三个子端;所有者保持到 CreateProcessW 返回,失败自动关闭,Host 不保留多余子端阻塞 EOF。 +- PreparedLaunch 新增带 stdio 的挂起创建路径,沿用创建前后租约检查、BoundEntry 全生命周期持有、AppContainer 身份核验和 Job 先绑定后恢复。返回 Host 管道端供实例运行线程持有,未引入共享监听地址、请求自报 PID 或包身份。无 stdio 的受控探针路径继续不继承任何句柄。 +- 新增有界 NDJSON 解码器:每帧最多 2 MiB,在扩展缓冲前检查;拒绝超长、空帧与 EOF 前未结束的帧,协议/读取错误后解码器不再继续解析。编码拒绝未转义换行与超限帧。该同步组件不自行解决阻塞读取/写入,需要后续 IO 取消所有者与工具期限控制;stderr 持续排空/限额也仍待运行编排接入。 +- 真实 AppContainer 原生探针经已签发参数/环境与解锁会话启动,通过 stdout 发出 notes.read,由绑定当前 Vault 和权限的文件 broker 处理,经 stdin 返回正文;子进程核对正文后正常退出。测试同时确认独立 stderr、关闭 stdin 产生 EOF、正文未改动,以及 Host 中另一个显式可继承的事件句柄未进入该子进程。仅使用隔离测试 Vault/虚构凭据。 +- 管道标志测试确认三个 Host 端全部非继承、三个子端继承且六个句柄互异;帧测试覆盖单字节分片、多帧、CRLF、恰好上限、超一字节、截断、错误后封闭及写入拒绝。57 项扩展回归通过、4 项 ignored 为既有三个父测试驱动辅助入口与显式 60 秒验收。desktop 全目标及非 desktop 库 Clippy -D warnings 均通过;首次非 desktop 检查指出两处桌面专用构造函数未限制编译条件,已修正。日志 `.build/extension-stdio-tests.log`、`.build/extension-stdio-clippy.log`、`.build/extension-stdio-core-clippy.log`。 +- API 依据 [Microsoft handle inheritance](https://learn.microsoft.com/en-us/windows/win32/procthread/inheritance)。明确句柄清单约束本次扩展创建,但 Host 内其他并发启动若使用不带清单的全量句柄继承,仍可能继承这段短暂窗口内的子端;正式启用前须审计并统一所有创建路径或采用隔离创建代理,不能仅凭本测试声明该并发风险消失。 +- 这增加真实沙箱至文件 broker 的通信证据,尚未接入用户安装实例注册、JSON-RPC/MCP 派发、活跃安装/信任复核、IO 超时/背压和完整句柄安全写入。第三方执行仍禁用,C-02/C-04 及完整生产化保持未通过状态。 diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index b58c621..324f9e5 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2" jsonschema = { version = "0.55", default-features = false } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } [build-dependencies] tauri-build = { version = "2", optional = true , features = [] } diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 9ad87ac..2029a61 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -774,6 +774,90 @@ mod tests { Some(0) ); drop(running); + // Actual native RPC: the child cannot name an identity or connect to + // a shared endpoint; only its own stdio pipe reaches this broker. + { + use std::{ + io::{BufReader, Read}, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + }; + let sentinel = unsafe { + windows_sys::Win32::System::Threading::CreateEventW( + std::ptr::null(), + 1, + 0, + std::ptr::null(), + ) + }; + assert!(!sentinel.is_null()); + let sentinel = unsafe { OwnedHandle::from_raw_handle(sentinel) }; + assert_ne!( + unsafe { + windows_sys::Win32::Foundation::SetHandleInformation( + sentinel.as_raw_handle(), + windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT, + windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT, + ) + }, + 0 + ); + let vault = tempfile::tempdir().unwrap(); + let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap(); + workspace + .write("fixture.md", "", b"from host broker", "local") + .unwrap(); + let mut rpc = claims.clone(); + rpc.vault_id = workspace.vault_id.clone(); + rpc.arguments = vec![ + "file_rpc".into(), + (sentinel.as_raw_handle() as usize).to_string(), + ]; + rpc.permissions.insert("notes.read".into()); + rpc.expires_at_ms = 10_000; + let permit = authority.issue(&rpc, 1).unwrap(); + let rpc_context = Context { + vault_id: &rpc.vault_id, + ..context + }; + let prepared = rpc_context + .prepare(&authority, &permit, &rpc, &bound_entry, &broker, 2) + .unwrap(); + let mut files = crate::extension_file_broker::Broker::bind( + &authority, &permit, &rpc, &broker, &workspace, "1", 2, + ) + .unwrap(); + let (suspended, io) = prepared + .create_suspended_with_stdio(&profile, &bound_entry) + .unwrap(); + let running = unsafe { suspended.resume().unwrap() }; + let crate::extension_stdio::HostIo { + mut input, + output, + mut error, + } = io; + let mut output = crate::extension_stdio::Frames::new(BufReader::new(output)); + let request = output.read().unwrap().unwrap(); + let response = files.dispatch(&mut workspace, &request).unwrap(); + crate::extension_stdio::write_frame( + &mut input, + &serde_json::to_vec(&response).unwrap(), + ) + .unwrap(); + drop(input); + assert_eq!( + running.wait(std::time::Duration::from_secs(5)).unwrap(), + Some(0) + ); + assert_eq!(output.read().unwrap().unwrap(), b"{\"ok\":true}"); + assert!(output.read().unwrap().is_none()); + let mut diagnostic = String::new(); + error.read_to_string(&mut diagnostic).unwrap(); + assert_eq!(diagnostic.trim(), "fixture diagnostic"); + assert_eq!( + workspace.read("fixture.md").unwrap().content, + "from host broker" + ); + } for cause in [ "before_create", "before_resume", diff --git a/frontend/src-tauri/src/extension_file_broker.rs b/frontend/src-tauri/src/extension_file_broker.rs index 1bd5da6..3728b8c 100644 --- a/frontend/src-tauri/src/extension_file_broker.rs +++ b/frontend/src-tauri/src/extension_file_broker.rs @@ -19,7 +19,7 @@ use std::{ }; use windows_sys::Win32::Storage::FileSystem::*; -pub const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024; +pub use crate::extension_stdio::MAX_FRAME_BYTES; pub const MAX_NOTE_BYTES: usize = 1024 * 1024; const REQUESTS_PER_SECOND: u32 = 32; #[derive(Deserialize)] diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs index a3c42b8..8911906 100644 --- a/frontend/src-tauri/src/extension_launch_authorization.rs +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -50,6 +50,30 @@ impl PreparedLaunch { lease: self.lease, }) } + pub fn create_suspended_with_stdio<'a>( + self, + profile: &'a crate::extension_container::Profile, + entry: &'a BoundEntry<'a>, + ) -> Result<(LeasedSuspended<'a>, crate::extension_stdio::HostIo)> { + self.lease.check()?; + if self.path != entry.path() + || self.entry != entry.relative_name() + || self.tree != entry.tree_sha256() + { + return Err(HostError::new("EXTENSION_ENTRY_PERMIT_MISMATCH")); + } + let (process, io) = crate::extension_process::Suspended::create_bound_with_stdio( + profile, entry, self.data, + )?; + self.lease.check()?; + Ok(( + LeasedSuspended { + process, + lease: self.lease, + }, + io, + )) + } } impl<'a> LeasedSuspended<'a> { /// # Safety diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index 6d1349e..eb2b61b 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -26,11 +26,11 @@ struct Attributes { initialized: bool, } impl Attributes { - fn new() -> Result { + fn new(count: u32) -> Result { let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED"); let mut bytes = 0; unsafe { - InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes); + InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &mut bytes); } if bytes == 0 || bytes > 65536 { return Err(bad()); @@ -40,7 +40,12 @@ impl Attributes { initialized: false, }; if unsafe { - InitializeProcThreadAttributeList(value.buffer.as_mut_ptr().cast(), 1, 0, &mut bytes) + InitializeProcThreadAttributeList( + value.buffer.as_mut_ptr().cast(), + count, + 0, + &mut bytes, + ) } == 0 { return Err(bad()); @@ -94,7 +99,15 @@ impl<'a> Suspended<'a> { /// Creates hidden, with no inherited handles and an explicit environment and /// current directory. The profile borrow prevents cleanup while this owner /// exists. This API never resumes extension instructions. - pub fn create(profile: &'a Profile, executable: &Path, mut data: LaunchData) -> Result { + pub fn create(profile: &'a Profile, executable: &Path, data: LaunchData) -> Result { + Self::create_inner(profile, executable, data, None) + } + fn create_inner( + profile: &'a Profile, + executable: &Path, + mut data: LaunchData, + io: Option, + ) -> Result { let bad = || HostError::new("EXTENSION_PROCESS_CREATE_FAILED"); if !executable.is_absolute() || data.command_mut().last() != Some(&0) { return Err(bad()); @@ -106,7 +119,7 @@ impl<'a> Suspended<'a> { let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect(); let folder = profile.folder()?; let directory: Vec = folder.as_os_str().encode_wide().chain(Some(0)).collect(); - let mut attributes = Attributes::new()?; + let mut attributes = Attributes::new(if io.is_some() { 2 } else { 1 })?; let caps = SECURITY_CAPABILITIES { AppContainerSid: profile.sid(), Capabilities: std::ptr::null_mut(), @@ -131,6 +144,29 @@ impl<'a> Suspended<'a> { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = size_of::() as u32; startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast(); + // Keep both the handle array and the owning pipe ends alive across + // CreateProcessW. No arbitrary inheritable Host handle is admitted. + let inherited = io.as_ref().map(|value| value.handles()); + if let Some(handles) = &inherited { + if unsafe { + UpdateProcThreadAttribute( + attributes.buffer.as_mut_ptr().cast(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + handles.as_ptr().cast(), + size_of::<[windows_sys::Win32::Foundation::HANDLE; 3]>(), + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED")); + } + startup.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = handles[0]; + startup.StartupInfo.hStdOutput = handles[1]; + startup.StartupInfo.hStdError = handles[2]; + } let mut info = PROCESS_INFORMATION::default(); let environment = data.environment().as_ptr(); if unsafe { @@ -139,7 +175,7 @@ impl<'a> Suspended<'a> { data.command_mut().as_mut_ptr(), std::ptr::null(), std::ptr::null(), - 0, + i32::from(io.is_some()), CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT @@ -182,6 +218,19 @@ impl<'a> Suspended<'a> { value.0._bound_entry = Some(entry); Ok(value) } + /// Create instance-specific stdio without exposing an address or trusting a + /// self-reported process/package identity. Host endpoints are never inherited. + #[cfg(feature = "desktop")] + pub fn create_bound_with_stdio( + profile: &'a Profile, + entry: &'a crate::extension_pinned::BoundEntry<'a>, + data: LaunchData, + ) -> Result<(Self, crate::extension_stdio::HostIo)> { + let (child, host) = crate::extension_stdio::ChildIo::create()?; + let mut value = Self::create_inner(profile, entry.path(), data, Some(child))?; + value.0._bound_entry = Some(entry); + Ok((value, host)) + } /// # Safety /// Caller must hold the verified package/entry handles and revalidate the /// current execution permit, trust, Vault binding, environment declarations, diff --git a/frontend/src-tauri/src/extension_stdio.rs b/frontend/src-tauri/src/extension_stdio.rs new file mode 100644 index 0000000..5bc5e25 --- /dev/null +++ b/frontend/src-tauri/src/extension_stdio.rs @@ -0,0 +1,214 @@ +//! Per-launch anonymous pipes. Only child ends enter the explicit inheritance +//! list. The runtime owns Host ends and must bound frames and cancel blocked IO. +use crate::workspace::{HostError, Result}; +#[cfg(any(feature = "desktop", test))] +use std::os::windows::io::FromRawHandle; +use std::{ + fs::File, + os::windows::io::{AsRawHandle, OwnedHandle}, +}; +use windows_sys::Win32::Foundation::*; +#[cfg(any(feature = "desktop", test))] +use windows_sys::Win32::System::Pipes::CreatePipe; + +pub struct HostIo { + pub input: File, + pub output: File, + pub error: File, +} +pub(crate) struct ChildIo { + input: OwnedHandle, + output: OwnedHandle, + error: OwnedHandle, +} +#[cfg(any(feature = "desktop", test))] +fn pair() -> Result<(OwnedHandle, OwnedHandle)> { + let mut read = std::ptr::null_mut(); + let mut write = std::ptr::null_mut(); + if unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 4096) } == 0 { + return Err(HostError::new("EXTENSION_PIPE_CREATE_FAILED")); + } + Ok(unsafe { + ( + OwnedHandle::from_raw_handle(read), + OwnedHandle::from_raw_handle(write), + ) + }) +} +impl ChildIo { + #[cfg(any(feature = "desktop", test))] + pub(crate) fn create() -> Result<(Self, HostIo)> { + let (input, host_input) = pair()?; + let (host_output, output) = pair()?; + let (host_error, error) = pair()?; + let child = Self { + input, + output, + error, + }; + for handle in child.handles() { + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } + == 0 + { + return Err(HostError::new("EXTENSION_PIPE_CREATE_FAILED")); + } + } + Ok(( + child, + HostIo { + input: host_input.into(), + output: host_output.into(), + error: host_error.into(), + }, + )) + } + pub(crate) fn handles(&self) -> [HANDLE; 3] { + [ + self.input.as_raw_handle(), + self.output.as_raw_handle(), + self.error.as_raw_handle(), + ] + } +} + +/// NDJSON maximum excludes the line terminator. A protocol/IO error poisons +/// the decoder; the runtime must terminate the instance and close its pipes. +/// This synchronous decoder needs a separate IO cancellation/deadline owner. +pub const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024; +pub struct Frames { + reader: R, + failed: bool, +} +impl Frames { + pub fn new(reader: R) -> Self { + Self { + reader, + failed: false, + } + } + pub fn read(&mut self) -> Result>> { + if self.failed { + return Err(HostError::new("EXTENSION_PIPE_CLOSED")); + } + let result = self.read_inner(); + if result.is_err() { + self.failed = true; + } + result + } + fn read_inner(&mut self) -> Result>> { + let mut frame = Vec::new(); + loop { + let available = self + .reader + .fill_buf() + .map_err(|_| HostError::new("EXTENSION_PIPE_READ_FAILED"))?; + if available.is_empty() { + return if frame.is_empty() { + Ok(None) + } else { + Err(HostError::new("EXTENSION_PIPE_TRUNCATED_FRAME")) + }; + } + let end = available.iter().position(|b| *b == b'\n'); + let count = end.unwrap_or(available.len()); + if count > MAX_FRAME_BYTES - frame.len() { + return Err(HostError::new("EXTENSION_BROKER_REQUEST_TOO_LARGE")); + } + frame.extend_from_slice(&available[..count]); + self.reader.consume(count + usize::from(end.is_some())); + if end.is_some() { + if frame.last() == Some(&b'\r') { + frame.pop(); + } + if frame.is_empty() { + return Err(HostError::new("EXTENSION_PIPE_EMPTY_FRAME")); + } + return Ok(Some(frame)); + } + } + } +} +pub fn write_frame(writer: &mut impl std::io::Write, frame: &[u8]) -> Result<()> { + if frame.is_empty() + || frame.len() > MAX_FRAME_BYTES + || frame.contains(&b'\n') + || frame.contains(&b'\r') + { + return Err(HostError::new("EXTENSION_PIPE_INVALID_FRAME")); + } + writer + .write_all(frame) + .and_then(|_| writer.write_all(b"\n")) + .and_then(|_| writer.flush()) + .map_err(|_| HostError::new("EXTENSION_PIPE_WRITE_FAILED")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_child_ends_are_inheritable_and_all_streams_are_distinct() { + let (child, host) = ChildIo::create().unwrap(); + let ends = child + .handles() + .into_iter() + .chain([ + host.input.as_raw_handle(), + host.output.as_raw_handle(), + host.error.as_raw_handle(), + ]) + .collect::>(); + for (index, handle) in ends.iter().enumerate() { + let mut flags = 0; + assert_ne!(unsafe { GetHandleInformation(*handle, &mut flags) }, 0); + assert_eq!(flags & HANDLE_FLAG_INHERIT != 0, index < 3); + assert!(!ends[..index].contains(handle)); + } + } + #[test] + fn frames_are_bounded_across_fragmentation_and_poison_after_errors() { + use std::io::{BufReader, Cursor}; + let mut frames = Frames::new(BufReader::with_capacity( + 1, + Cursor::new(b"{\"id\":1}\n{\"id\":2}\r\n"), + )); + assert_eq!(frames.read().unwrap().unwrap(), b"{\"id\":1}"); + assert_eq!(frames.read().unwrap().unwrap(), b"{\"id\":2}"); + assert!(frames.read().unwrap().is_none()); + let mut largest = vec![b'x'; MAX_FRAME_BYTES]; + largest.push(b'\n'); + assert_eq!( + Frames::new(BufReader::with_capacity(127, Cursor::new(&largest))) + .read() + .unwrap() + .unwrap() + .len(), + MAX_FRAME_BYTES + ); + largest.insert(0, b'x'); + let mut overflow = Frames::new(BufReader::with_capacity(127, Cursor::new(largest))); + assert_eq!( + overflow.read().unwrap_err().code, + "EXTENSION_BROKER_REQUEST_TOO_LARGE" + ); + assert_eq!(overflow.read().unwrap_err().code, "EXTENSION_PIPE_CLOSED"); + let mut truncated = Frames::new(Cursor::new(b"{}")); + assert_eq!( + truncated.read().unwrap_err().code, + "EXTENSION_PIPE_TRUNCATED_FRAME" + ); + assert_eq!(truncated.read().unwrap_err().code, "EXTENSION_PIPE_CLOSED"); + assert_eq!( + Frames::new(Cursor::new(b"\n")).read().unwrap_err().code, + "EXTENSION_PIPE_EMPTY_FRAME" + ); + let mut output = Vec::new(); + for invalid in [&b""[..], &b"{}\n{}"[..], &b"{}\r"[..]] { + assert!(write_frame(&mut output, invalid).is_err()); + assert!(output.is_empty()); + } + write_frame(&mut output, b"{}").unwrap(); + assert_eq!(output, b"{}\n"); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 7742f8e..726f47f 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -78,3 +78,6 @@ mod extension_revocation; #[cfg(all(windows, feature = "desktop"))] pub mod extension_file_broker; + +#[cfg(windows)] +pub mod extension_stdio; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index 480015e..fb2003b 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -3,6 +3,22 @@ use std::net::{SocketAddr, TcpStream, UdpSocket}; use std::time::Duration; fn main() { let args: Vec<_> = std::env::args().collect(); + if args.get(1).is_some_and(|s| s == "file_rpc") { + use std::io::{Read, Write}; + #[link(name = "kernel32")] + extern "system" { fn GetHandleInformation(handle: *mut std::ffi::c_void, flags: *mut u32) -> i32; } + let sentinel: usize = args[2].parse().unwrap(); + let mut flags = 0; + if unsafe { GetHandleInformation(sentinel as *mut _, &mut flags) } != 0 { std::process::exit(85); } + println!("{{\"method\":\"notes.read\",\"path\":\"fixture.md\"}}"); + std::io::stdout().flush().unwrap(); + let mut response = String::new(); + std::io::stdin().take(4096).read_to_string(&mut response).unwrap(); + if !response.contains("\"content\":\"from host broker\"") { std::process::exit(86); } + println!("{{\"ok\":true}}"); + eprintln!("fixture diagnostic"); + return; + } if args.get(1).is_some_and(|s| s == "wait_tree") { let mut child = std::process::Command::new(std::env::current_exe().unwrap()) .arg("wait")