diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 3e77e59..dd4241e 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -542,3 +542,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 此回收机制不保证 Host 被强杀后的残留恢复,也尚未验证外部 ACL 修改竞争、清理系统调用故障及全部恶意文件攻击路径。第三方扩展执行能力仍禁用,生产化总目标未完成。 - 修正后 desktop 全目标测试累计 128 通过、7 ignored:库 113、Host 8,其余集成 7;ignored 包含显式长时验收和由父测试驱动的辅助进程入口。日志 `.build/extension-acl-release-full.log`。其中实际 Sync 服务的有序推送/重复提交及故障恢复集成也通过。 - 另行执行真实 MCP 60 秒工具超时验收通过,整项原生协议/网络探针测试耗时 76.77 秒,日志 `.build/extension-acl-release-sixty.log`;desktop 全目标 Clippy -D warnings 通过,日志 `.build/extension-acl-release-clippy.log`。这不是完整沙箱攻击矩阵或远端部署验收通过的证据。 + + +## 增量:CPU 超限原生通知与后台终止 + +- 在现有 Job 单核等效 HARD_CAP 上启用 NOTIFY,在进程分配/恢复前关联独立 I/O completion port,并配置 Windows 的短 10 秒通知窗口与 ToleranceHigh。原生探针在本机 16 逻辑处理器上观察到首次 CPU 通知约 10.028 秒、下一次约 24.029 秒,ViolationLimitFlags=262144;说明该通知可与硬限额共存,但不能把通知间隔假定为固定 10 秒。日志 `.build/extension-cpu-probe.log`。 +- Job 原始所有者持有独立 CPU 监视线程。线程收到通知后查询 JobObjectLimitViolationInformation,确认 CPU 标志才报告 EXTENSION_RESOURCE_CPU_EXCEEDED 并终止整个 Job;完成端口/查询失败或捕获到 panic 走失败终止,终止 API 失败有独立 EXTENSION_RESOURCE_TERMINATE_FAILED。观察与工具期限句柄克隆不拥有监视线程,原 Job 析构会通知线程停止并 join,线程仍持有 Job 句柄执行清理。 +- 分配挂起进程、恢复执行及 Running.check_authorization 加入资源状态检查,因此既有 MCP 与实例后台路径可观察到 CPU 资源错误;CPU 终止不依赖 renderer 或调用方持续轮询。监视器初始化失败阻止 Job 返回成功。 +- Windows [CPU rate control](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information) 的硬上限按调度周期控制 CPU 周期;[notification limit](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_notification_limit_information) 的 ToleranceHigh 指允许在窗口的 60% 时间超过预算。这是 Windows 的窗口阈值保护,并非精确测量“连续超限 10 秒”。当前实现不据此宣称规划中的连续时限已获完整证明,也没有修改或放宽原验收阈值。 +- 新增显式真实 CPU 压力测试:Job 内 8 个忙线程、读取实际已配置 CPU 控制标志与比率、超限后确认 Job 清空、Host 在压力期间及清理后写入并重开验证笔记;另一个空闲 Job 应继续运行,持有观察句柄也不能阻止原 Job 所有者退出后的清理。该测试用原生 Job 测试进程,还不是已安装第三方 AppContainer 的完整 C-04 攻击验收。 +- 默认扩展回归首次发现旧 CPU 控制标志测试仍只期望 ENABLE|HARD_CAP,已更新为同时核验新 NOTIFY 标志;未放宽资源限额值。scratch 硬配额、CPU 连续时限严格证明、全部限额清理矩阵、Host 实际产品路由及其他生产验收继续未完成,第三方执行能力仍禁用。 +- 最终默认扩展回归 68 通过、7 ignored(长时验收与辅助进程入口),日志 `.build/extension-cpu-regression.log`;显式 CPU 压力/空闲对照测试通过,本次从恢复附近的计时点到检测为 15.7348341 秒,整个测试 15.90 秒,日志 `.build/extension-cpu-enforcement.log`。此前简化压力测试为 12.1788269 秒;这些差异进一步表明不能宣称精确 10 秒触发。记录中的“Job empty after”还包含后续 Host 保存/重开工作,不能作为纯终止 API 延迟的独立测量。 +- desktop 全目标 Clippy -D warnings 通过,日志 `.build/extension-cpu-clippy.log`。本轮改动集中于原生 Job 与进程资源状态传播,未重新声称上一轮的 Rust 全目标、前端、Python 和远端验收均对当前版本完成。 diff --git a/frontend/src-tauri/src/extension_job.rs b/frontend/src-tauri/src/extension_job.rs index 989e4ec..bca2936 100644 --- a/frontend/src-tauri/src/extension_job.rs +++ b/frontend/src-tauri/src/extension_job.rs @@ -11,10 +11,14 @@ use windows_sys::Win32::System::{ pub struct Job { handle: OwnedHandle, + state: std::sync::Arc, + _cpu: Option, } impl Job { pub(crate) fn clone_for_deadline(&self) -> Result { Ok(Self { + state: std::sync::Arc::clone(&self.state), + _cpu: None, handle: self .handle .try_clone() @@ -29,8 +33,10 @@ impl Job { if raw.is_null() { return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE")); } - let job = Self { + let mut job = Self { handle: unsafe { OwnedHandle::from_raw_handle(raw) }, + state: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + _cpu: None, }; let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION { BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION { @@ -49,14 +55,26 @@ impl Job { 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, + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE + | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP + | JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY, Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { CpuRate: (10000 / processors).max(1), }, }; job.set(JobObjectCpuRateControlInformation, &cpu)?; + job._cpu = Some(CpuMonitor::arm(&job)?); Ok(job) } + pub fn check_resources(&self) -> Result<()> { + use std::sync::atomic::Ordering; + match self.state.load(Ordering::Acquire) { + 0 => Ok(()), + 1 => Err(HostError::new("EXTENSION_RESOURCE_CPU_EXCEEDED")), + 3 => Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED")), + _ => Err(HostError::new("EXTENSION_RESOURCE_MONITOR_FAILED")), + } + } fn set(&self, class: JOBOBJECTINFOCLASS, value: &T) -> Result<()> { if unsafe { SetInformationJobObject( @@ -77,6 +95,7 @@ impl Job { /// 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<()> { + self.check_resources()?; if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) } == 0 { @@ -108,6 +127,122 @@ impl Job { } } +/// The Windows notification uses a ten-second window and ToleranceHigh (60% +/// over budget). This is not a measurement of ten uninterrupted busy seconds. +/// Only the original Job owns this monitor; observation/deadline clones do not. +const JOB_NOTIFICATION_LIMIT: u32 = 11; // JOB_OBJECT_MSG_NOTIFICATION_LIMIT (Windows SDK) +struct CpuMonitor { + stop: std::sync::Arc, + worker: Option>, +} +impl CpuMonitor { + fn arm(job: &Job) -> Result { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + use windows_sys::Win32::{Foundation::INVALID_HANDLE_VALUE, System::IO::*}; + let raw = + unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, std::ptr::null_mut(), 0, 1) }; + if raw.is_null() { + return Err(HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE")); + } + let port = unsafe { OwnedHandle::from_raw_handle(raw) }; + job.set( + JobObjectAssociateCompletionPortInformation, + &JOBOBJECT_ASSOCIATE_COMPLETION_PORT { + CompletionKey: std::ptr::dangling_mut::().cast(), + CompletionPort: port.as_raw_handle(), + }, + )?; + job.set( + JobObjectNotificationLimitInformation, + &JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION { + LimitFlags: JOB_OBJECT_LIMIT_RATE_CONTROL, + RateControlTolerance: ToleranceHigh, + RateControlToleranceInterval: ToleranceIntervalShort, + ..Default::default() + }, + )?; + let owned_job = job + .handle + .try_clone() + .map_err(|_| HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE"))?; + let state = Arc::clone(&job.state); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let worker = std::thread::Builder::new() + .name("extension-cpu".into()) + .spawn(move || { + // All exits, including a caught panic or completion-port failure, + // terminate the tree while this worker still owns a Job handle. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + while !thread_stop.load(Ordering::Acquire) { + let (mut code, mut key, mut pointer) = (0, 0, std::ptr::null_mut()); + let ok = unsafe { + GetQueuedCompletionStatus( + port.as_raw_handle(), + &mut code, + &mut key, + &mut pointer, + 100, + ) + }; + if ok == 0 { + if unsafe { windows_sys::Win32::Foundation::GetLastError() } + == windows_sys::Win32::Foundation::WAIT_TIMEOUT + { + continue; + } + return 2; + } + if key != 1 { + return 2; + } + if code != JOB_NOTIFICATION_LIMIT { + continue; + } + let mut info = JOBOBJECT_LIMIT_VIOLATION_INFORMATION::default(); + if unsafe { + QueryInformationJobObject( + owned_job.as_raw_handle(), + JobObjectLimitViolationInformation, + (&mut info as *mut JOBOBJECT_LIMIT_VIOLATION_INFORMATION).cast(), + size_of::() as u32, + std::ptr::null_mut(), + ) + } == 0 + { + return 2; + } + if info.ViolationLimitFlags & JOB_OBJECT_LIMIT_RATE_CONTROL != 0 { + return 1; + } + } + 0 + })) + .unwrap_or(2); + state.store(outcome, Ordering::Release); + if unsafe { TerminateJobObject(owned_job.as_raw_handle(), 1) } == 0 { + state.store(3, Ordering::Release); + } + }) + .map_err(|_| HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE"))?; + Ok(Self { + stop, + worker: Some(worker), + }) + } +} +impl Drop for CpuMonitor { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -199,6 +334,114 @@ mod tests { assert!(excessive.try_reserve_exact(600 * 1024 * 1024).is_err()); } #[test] + #[ignore = "helper consumes CPU inside the parent controlled job"] + fn worker_cpu() { + let end = std::time::Instant::now() + Duration::from_secs(40); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(move || { + let mut n = 1u64; + while std::time::Instant::now() < end { + for _ in 0..10000 { + n = std::hint::black_box( + n.wrapping_mul(6364136223846793005).wrapping_add(1), + ); + } + } + }); + } + }); + } + #[test] + #[ignore = "real ten-second CPU pressure and Host save acceptance; run explicitly"] + fn cpu_pressure_terminates_job_and_host_can_save() { + let idle_job = Job::new().unwrap(); + let idle = worker(); + unsafe { + idle_job.assign_suspended(idle.process.as_handle()).unwrap(); + assert_ne!(ResumeThread(idle.thread.as_raw_handle()), u32::MAX); + } + let job = Job::new().unwrap(); + let mut rate = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default(); + assert_ne!( + unsafe { + QueryInformationJobObject( + job.handle.as_raw_handle(), + JobObjectCpuRateControlInformation, + (&mut rate as *mut JOBOBJECT_CPU_RATE_CONTROL_INFORMATION).cast(), + size_of::() as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + rate.ControlFlags, + JOB_OBJECT_CPU_RATE_CONTROL_ENABLE + | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP + | JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY + ); + assert_eq!( + unsafe { rate.Anonymous.CpuRate }, + (10000 / unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }).max(1) + ); + let child = worker_named("worker_cpu"); + let start = std::time::Instant::now(); + unsafe { + job.assign_suspended(child.process.as_handle()).unwrap(); + assert_ne!(ResumeThread(child.thread.as_raw_handle()), u32::MAX); + } + let vault = tempfile::tempdir().unwrap(); + let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap(); + workspace + .write("cpu.md", "", b"while CPU is busy", "local") + .unwrap(); + while job.check_resources().is_ok() && start.elapsed() < Duration::from_secs(20) { + std::thread::sleep(Duration::from_millis(25)); + } + assert_eq!( + job.check_resources().unwrap_err().code, + "EXTENSION_RESOURCE_CPU_EXCEEDED" + ); + let detected = start.elapsed(); + let cleanup = std::time::Instant::now(); + while job.active_processes().unwrap() != 0 && cleanup.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job.active_processes().unwrap(), 0); + let saved = workspace.read("cpu.md").unwrap(); + workspace + .write("cpu.md", &saved.entry.hash, b"after CPU cleanup", "local") + .unwrap(); + drop(workspace); + assert_eq!( + crate::workspace::Workspace::open(vault.path()) + .unwrap() + .read("cpu.md") + .unwrap() + .content, + "after CPU cleanup" + ); + eprintln!( + "CPU pressure detected at {detected:?}; Job empty after {:?}", + cleanup.elapsed() + ); + idle_job.check_resources().unwrap(); + assert!(idle_job.active_processes().unwrap() > 0); + // An observation handle must not keep the monitor alive after its owner + // is dropped, or keep the idle process running indefinitely. + let observation = idle_job.clone_for_deadline().unwrap(); + let stop = std::time::Instant::now(); + drop(idle_job); + assert!(stop.elapsed() < Duration::from_secs(2)); + while observation.active_processes().unwrap() != 0 + && stop.elapsed() < Duration::from_secs(5) + { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(observation.active_processes().unwrap(), 0); + } + #[test] fn actual_allocation_above_job_memory_budget_is_refused() { let job = Job::new().unwrap(); let child = worker_named("worker_memory"); @@ -306,7 +549,9 @@ mod tests { ); assert_eq!( cpu.ControlFlags, - JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP + JOB_OBJECT_CPU_RATE_CONTROL_ENABLE + | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP + | JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY ); let child = worker(); unsafe { diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index e9d85c5..5eafa73 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -239,6 +239,7 @@ impl<'a> Suspended<'a> { /// broker and all resource policy requirements immediately before this call. /// None of those authorization checks is supplied by this low-level module. pub unsafe fn resume(self) -> Result> { + self.0.job.check_resources()?; if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED")); } @@ -261,6 +262,7 @@ impl<'a> Suspended<'a> { ) -> Result> { let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?; watch.check()?; + self.0.job.check_resources()?; if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED")); } @@ -291,6 +293,7 @@ impl Running<'_> { } pub fn check_authorization(&self) -> Result<()> { + self.process.job.check_resources()?; #[cfg(feature = "desktop")] if let Some(watch) = &self.revocation { return watch.check();