diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index b39a802..739d555 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -312,3 +312,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 任一后续键发生 revision 冲突时,前面已执行的更新全部回滚;确认凭据只在整组成功后一起消耗。此变更替代此前文档中逐键确认可能部分成功的流程。 - 34 项扩展回归、2 项 Host 确认测试、3 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过。日志 `.build/extension-trust-group-tests.log`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。 - 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。 + + +## 增量:Windows Job Object 资源容器 + +- 新增 extension_job,创建无可继承句柄的 Job,设置组提交内存 512 MiB、活动进程默认 16、关闭最后句柄时杀死进程;不启用 breakaway。CPU 使用 hard cap,按系统活动逻辑处理器数向下取整分配至单核等效份额;任一设置失败不返回可用容器。 +- assign_suspended 明确要求调用方持有尚未恢复的 CREATE_SUSPENDED 进程,出错须终止它,全部隔离/许可检查后才能 ResumeThread。该模块只有资源约束,尚未接入第三方运行入口,也不具备文件、网络或身份隔离。 +- 4 项真实 Windows 进程测试与全目标 Clippy -D warnings 通过,日志 `.build/extension-job-tests.log`。三个 ignored 条目是由父测试实际启动的辅助入口。验证进程配额为 1 时第二进程被拒绝且从未恢复、关闭 Job 后进程退出、受管理后代计数归零、显式终止,以及正常 32 MiB 分配成功/600 MiB 分配失败。 +- 测试初次使用固定等待观察进程计数失败,改为最多 5 秒等待计数收敛后通过;不将单次固定延时当作清理证明。生产参数测试读取 OS 返回的内存、进程和 CPU 配置,但尚未完成长时间 CPU 压力、10 秒超限终止及 scratch 配额测试。 +- API 依据 Microsoft Job Objects 与 JOBOBJECT_CPU_RATE_CONTROL_INFORMATION 文档: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects 、 https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information 。AppContainer、文件/网络 broker、恶意程序矩阵及实际扩展调用仍待完成,不能据此通过 C 项或开启 extensions capability。 diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index ae60b1a..a103b41 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_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_System_JobObjects", "Win32_System_Threading", "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_job.rs b/frontend/src-tauri/src/extension_job.rs new file mode 100644 index 0000000..7bcb6c9 --- /dev/null +++ b/frontend/src-tauri/src/extension_job.rs @@ -0,0 +1,313 @@ +//! Windows resource containment only. This is NOT a filesystem/network sandbox. +use crate::workspace::{HostError, Result}; +use std::{ + mem::size_of, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, +}; +use windows_sys::Win32::System::{ + JobObjects::*, + Threading::{GetActiveProcessorCount, ALL_PROCESSOR_GROUPS}, +}; + +pub struct Job { + handle: OwnedHandle, +} +impl Job { + pub fn new() -> Result { + Self::with_process_limit(16) + } + fn with_process_limit(processes: u32) -> Result { + let raw = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if raw.is_null() { + return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE")); + } + let job = Self { + handle: unsafe { OwnedHandle::from_raw_handle(raw) }, + }; + let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION { + LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_JOB_MEMORY, + ActiveProcessLimit: processes, + ..Default::default() + }, + JobMemoryLimit: 512 * 1024 * 1024, + ..Default::default() + }; + job.set(JobObjectExtendedLimitInformation, &limits)?; + let processors = unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }; + if processors == 0 { + return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE")); + } + let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, + Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { + CpuRate: (10000 / processors).max(1), + }, + }; + job.set(JobObjectCpuRateControlInformation, &cpu)?; + Ok(job) + } + fn set(&self, class: JOBOBJECTINFOCLASS, value: &T) -> Result<()> { + if unsafe { + SetInformationJobObject( + self.handle.as_raw_handle(), + class, + (value as *const T).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE")); + } + Ok(()) + } + /// Attach before any extension instruction executes. No breakaway flags are enabled. + /// + /// # Safety + /// Caller must own an unresumed CREATE_SUSPENDED process and terminate it on + /// any error. Resume only after all AppContainer/handle/permission checks pass. + pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> { + if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) } + == 0 + { + return Err(HostError::new("EXTENSION_RESOURCE_ASSIGN_FAILED")); + } + Ok(()) + } + pub fn terminate(&self) -> Result<()> { + if unsafe { TerminateJobObject(self.handle.as_raw_handle(), 1) } == 0 { + return Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED")); + } + Ok(()) + } + pub fn active_processes(&self) -> Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + if unsafe { + QueryInformationJobObject( + self.handle.as_raw_handle(), + JobObjectBasicAccountingInformation, + (&mut accounting as *mut JOBOBJECT_BASIC_ACCOUNTING_INFORMATION).cast(), + size_of::() as u32, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(HostError::new("EXTENSION_RESOURCE_QUERY_FAILED")); + } + Ok(accounting.ActiveProcesses) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::windows::process::CommandExt; + use std::{ + os::windows::{ffi::OsStrExt, io::AsHandle}, + time::Duration, + }; + use windows_sys::Win32::{Foundation::WAIT_OBJECT_0, System::Threading::*}; + struct Worker { + process: OwnedHandle, + thread: OwnedHandle, + } + impl Drop for Worker { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), 1); + WaitForSingleObject(self.process.as_raw_handle(), 5000); + } + } + } + fn worker() -> Worker { + worker_named("worker_wait") + } + fn worker_named(name: &str) -> Worker { + let exe = std::env::current_exe().unwrap(); + let app: Vec = exe.as_os_str().encode_wide().chain(Some(0)).collect(); + let command = format!( + "\"{}\" --ignored --exact extension_job::tests::{name} --nocapture", + exe.display() + ); + let mut command: Vec = command.encode_utf16().chain(Some(0)).collect(); + let startup = STARTUPINFOW { + cb: size_of::() as u32, + ..Default::default() + }; + let mut info = PROCESS_INFORMATION::default(); + let empty_environment = [0u16, 0]; + assert_ne!( + unsafe { + CreateProcessW( + app.as_ptr(), + command.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + empty_environment.as_ptr().cast(), + std::ptr::null(), + &startup, + &mut info, + ) + }, + 0 + ); + Worker { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + } + } + #[test] + #[ignore = "helper process launched suspended by resource containment test"] + fn worker_wait() { + std::thread::sleep(Duration::from_secs(30)); + } + #[test] + #[ignore = "helper process creates a managed descendant for the parent test"] + fn worker_tree() { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .creation_flags(CREATE_NO_WINDOW) + .args([ + "--ignored", + "--exact", + "extension_job::tests::worker_wait", + "--nocapture", + ]) + .spawn() + .unwrap(); + child.wait().unwrap(); + } + #[test] + #[ignore = "helper probes allocations inside the parent's limited job"] + fn worker_memory() { + let mut normal = Vec::::new(); + normal.try_reserve_exact(32 * 1024 * 1024).unwrap(); + normal.resize(32 * 1024 * 1024, 1); + assert_eq!(normal[normal.len() - 1], 1); + let mut excessive = Vec::::new(); + assert!(excessive.try_reserve_exact(600 * 1024 * 1024).is_err()); + } + #[test] + fn actual_allocation_above_job_memory_budget_is_refused() { + let job = Job::new().unwrap(); + let child = worker_named("worker_memory"); + unsafe { + job.assign_suspended(child.process.as_handle()).unwrap(); + assert_ne!(ResumeThread(child.thread.as_raw_handle()), u32::MAX); + } + assert_eq!( + unsafe { WaitForSingleObject(child.process.as_raw_handle(), 10000) }, + WAIT_OBJECT_0 + ); + let mut code = 1; + assert_ne!( + unsafe { GetExitCodeProcess(child.process.as_raw_handle(), &mut code) }, + 0 + ); + assert_eq!(code, 0); + } + #[test] + fn managed_descendants_terminate_with_their_job() { + let job = Job::new().unwrap(); + let root = worker_named("worker_tree"); + unsafe { + job.assign_suspended(root.process.as_handle()).unwrap(); + assert_ne!(ResumeThread(root.thread.as_raw_handle()), u32::MAX); + } + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while job.active_processes().unwrap() < 2 && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job.active_processes().unwrap(), 2); + job.terminate().unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(root.process.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while job.active_processes().unwrap() != 0 && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job.active_processes().unwrap(), 0); + } + #[test] + fn actual_suspended_process_assignment_limits_and_close_cleanup() { + let job = Job::with_process_limit(1).unwrap(); + let first = worker(); + unsafe { + job.assign_suspended(first.process.as_handle()).unwrap(); + } + assert_eq!(job.active_processes().unwrap(), 1); + let second = worker(); + assert!(unsafe { job.assign_suspended(second.process.as_handle()) }.is_err()); + // The second process has never been resumed, even after assignment failure. + drop(second); + assert_ne!( + unsafe { ResumeThread(first.thread.as_raw_handle()) }, + u32::MAX + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while job.active_processes().unwrap() != 1 && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job.active_processes().unwrap(), 1); + drop(job); + assert_eq!( + unsafe { WaitForSingleObject(first.process.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); + } + #[test] + fn production_limits_are_configured_and_explicit_termination_reaps_process() { + let job = Job::new().unwrap(); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + assert_ne!( + unsafe { + QueryInformationJobObject( + job.handle.as_raw_handle(), + JobObjectExtendedLimitInformation, + (&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!(info.JobMemoryLimit, 512 * 1024 * 1024); + assert_eq!(info.BasicLimitInformation.ActiveProcessLimit, 16); + assert_eq!( + info.BasicLimitInformation.LimitFlags + & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK), + 0 + ); + let mut cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default(); + assert_ne!( + unsafe { + QueryInformationJobObject( + job.handle.as_raw_handle(), + JobObjectCpuRateControlInformation, + (&mut cpu as *mut JOBOBJECT_CPU_RATE_CONTROL_INFORMATION).cast(), + size_of::() as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + cpu.ControlFlags, + JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP + ); + let child = worker(); + unsafe { + job.assign_suspended(child.process.as_handle()).unwrap(); + } + job.terminate().unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(child.process.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 80c5733..bd575c3 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -51,3 +51,6 @@ pub mod extension_config; #[cfg(feature = "desktop")] pub mod extension_trust; + +#[cfg(windows)] +pub mod extension_job;