feat: 完成扩展资源验收与同步服务部署

This commit is contained in:
2026-09-10 02:57:38 +08:00
parent d703ab64e3
commit a3c098aa58
29 changed files with 464 additions and 112 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
// Visual fixtures only: all API traffic is intercepted; no provider calls or user data.
// 仅使用视觉夹具:拦截全部 API 流量,不调用提供商,也不读取用户数据。
const {chromium}=require('playwright');const fs=require('node:fs/promises');const path=require('node:path');
(async()=>{
const output=path.resolve(process.argv[2]||'.local-plans/phase2-review/themes');await fs.mkdir(output,{recursive:true});
+1 -1
View File
@@ -1,4 +1,4 @@
// Run with NODE_PATH pointing to the bundled Playwright package, or a local install.
// 运行时让 NODE_PATH 指向随包提供的 Playwright,或使用本地安装。
const { chromium } = require('playwright')
const fs = require('node:fs/promises')
const path = require('node:path')
+1 -1
View File
@@ -121,7 +121,7 @@ pub async fn extension_install_preview(
let workspace = host.workspace.clone();
let extensions = host.extensions.clone();
tauri::async_runtime::spawn_blocking(move || {
// Keep the workspace binding stable until this preview finishes.
// 在预览完成前保持工作区绑定不变。
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id {
return Err("VAULT_CHANGED".into());
+40 -9
View File
@@ -60,13 +60,43 @@ impl Profile {
/// 并须在整个启动期间持有已验证的包句柄。这里不使用递归继承,每个目录和文件都要分别检查、授权。
/// 此操作只会添加一条 ACE,不会清理已有权限。
pub fn grant_package_read_execute(&self, object: &std::fs::File) -> Result<()> {
self.update_package_access(object, false)
use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ};
self.update_access(
object,
false,
FILE_GENERIC_READ | FILE_GENERIC_EXECUTE,
0,
true,
)
}
/// 授予当前实例修改其专用 scratch 目录及新建子对象的权限。
pub fn grant_scratch_modify(&self, object: &std::fs::File) -> Result<()> {
use windows_sys::Win32::{
Security::SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Storage::FileSystem::{
DELETE, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
},
};
self.update_access(
object,
false,
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE,
SUB_CONTAINERS_AND_OBJECTS_INHERIT,
false,
)
}
/// 使用最初持有的对象句柄,仅移除这个新实例对应的允许 ACE;其他安全主体的 ACL 保持不变。
pub fn revoke_package_access(&self, object: &std::fs::File) -> Result<()> {
self.update_package_access(object, true)
self.update_access(object, true, 0, 0, false)
}
fn update_package_access(&self, object: &std::fs::File, revoke: bool) -> Result<()> {
fn update_access(
&self,
object: &std::fs::File,
revoke: bool,
permissions: u32,
inheritance: u32,
reject_hardlinks: bool,
) -> Result<()> {
// 跨并发实例序列化 Host 读/合并/写操作。
let _lock = PACKAGE_ACL_LOCK
.lock()
@@ -77,7 +107,7 @@ impl Profile {
Security::{Authorization::*, DACL_SECURITY_INFORMATION},
Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
FILE_ATTRIBUTE_REPARSE_POINT, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ,
FILE_ATTRIBUTE_REPARSE_POINT,
},
};
struct LocalAllocation(*mut core::ffi::c_void);
@@ -99,7 +129,7 @@ impl Profile {
{
return Err(HostError::new("EXTENSION_CONTAINER_ACL_OBJECT_INVALID"));
}
if metadata.is_file() && !revoke {
if metadata.is_file() && !revoke && reject_hardlinks {
let mut info = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(object.as_raw_handle(), &mut info) } == 0
|| info.nNumberOfLinks != 1
@@ -127,9 +157,9 @@ impl Profile {
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
}
let entry = EXPLICIT_ACCESS_W {
grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE,
grfAccessPermissions: permissions,
grfAccessMode: if revoke { REVOKE_ACCESS } else { GRANT_ACCESS },
grfInheritance: 0,
grfInheritance: inheritance,
Trustee: TRUSTEE_W {
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_UNKNOWN,
@@ -973,7 +1003,7 @@ mod tests {
mcp_modes.push("mcp_deadline");
}
if resources {
mcp_modes.extend(["mcp_cpu", "mcp_memory", "mcp_processes"]);
mcp_modes.extend(["mcp_cpu", "mcp_memory", "mcp_processes", "mcp_scratch"]);
}
for mode in mcp_modes {
use std::sync::{
@@ -1093,10 +1123,11 @@ mod tests {
);
assert!(session.take_tools_changed());
}
"mcp_cpu" | "mcp_memory" | "mcp_processes" => {
"mcp_cpu" | "mcp_memory" | "mcp_processes" | "mcp_scratch" => {
let expected = match mode {
"mcp_memory" => "EXTENSION_RESOURCE_MEMORY_EXCEEDED",
"mcp_processes" => "EXTENSION_RESOURCE_PROCESSES_EXCEEDED",
"mcp_scratch" => "EXTENSION_RESOURCE_SCRATCH_EXCEEDED",
_ => "EXTENSION_RESOURCE_CPU_EXCEEDED",
};
assert_eq!(result.unwrap_err().code, expected);
+10 -10
View File
@@ -152,7 +152,7 @@ pub struct Ticket<T> {
cancel: Arc<AtomicBool>,
}
impl<T> Ticket<T> {
/// Background wait only; dropping a ticket cancels its queued/in-flight work.
/// 仅供后台等待;丢弃票据会取消排队中或执行中的工作。
pub fn wait(self, timeout: Duration) -> Result<T> {
if timeout > Duration::from_secs(65) {
return Err(HostError::new("EXTENSION_INSTANCE_WAIT_INVALID"));
@@ -204,8 +204,8 @@ impl Endpoint {
request,
})
}
/// Host route only: the user must have approved the exact saved review.
/// Confirmation and consumption happen together on the instance thread.
/// 仅供 Host 路由使用:用户必须批准完全一致的已保存审查。
/// 确认和消费在实例线程上同步发生。
pub fn invoke_confirmed(&self, review_id: String) -> Result<Ticket<Value>> {
if uuid::Uuid::parse_str(&review_id).is_err() || review_id.len() != 36 {
return Err(HostError::new("EXTENSION_CALL_REVIEW_UNKNOWN"));
@@ -252,9 +252,9 @@ pub struct Registry {
}
impl Registry {
/// # Safety
/// The caller must establish all sandbox limits and current install/user
/// authorization. before_resume must recheck live trust/active installation.
/// This API is not exposed to renderer/Core and does not enable extensions.
/// 调用方必须建立全部沙箱限制以及当前安装和用户授权。
/// before_resume 必须重新检查实时信任与活动安装状态。
/// API 不向 renderer/Core 暴露,也不会自行启用扩展。
pub unsafe fn start(&mut self, spec: LaunchSpec) -> Result<Endpoint> {
self.reap();
spec.authority
@@ -299,7 +299,7 @@ impl Registry {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(spec, &control, receiver)
}));
// All native stack owners have dropped before publishing terminal state.
// 发布终止状态前,所有原生调用栈所有者均已销毁。
let error = match result {
Ok(Ok(())) => None,
Ok(Err(error))
@@ -335,8 +335,7 @@ impl Registry {
);
Ok(endpoint)
}
/// Reap only threads confirmed finished, so an old generation cannot overlap
/// a replacement merely because stop was requested or status was changed.
/// 只回收已确认结束的线程,不能仅因请求停止或状态改变就让旧代实例与替代实例重叠。
pub fn reap(&mut self) {
let done: Vec<_> = self
.entries
@@ -439,7 +438,7 @@ fn run_with_access(
if control.stop.load(Ordering::Acquire) {
return Ok(());
}
// Safety obligation belongs to Registry::start's caller, rechecked above.
// 安全义务属于 Registry::start 的调用方,并已在上方重新检查。
let running = unsafe { suspended.resume()? };
#[cfg(test)]
{
@@ -736,6 +735,7 @@ mod tests {
("mcp_cpu", "EXTENSION_RESOURCE_CPU_EXCEEDED"),
("mcp_memory", "EXTENSION_RESOURCE_MEMORY_EXCEEDED"),
("mcp_processes", "EXTENSION_RESOURCE_PROCESSES_EXCEEDED"),
("mcp_scratch", "EXTENSION_RESOURCE_SCRATCH_EXCEEDED"),
]
} else {
Vec::new()
+7 -11
View File
@@ -84,9 +84,8 @@ impl Drop for Worker {
fn drop(&mut self) {
self.state.stopped.store(true, Ordering::Release);
if let Some(thread) = self.thread.take() {
// Cancellation is not sticky: retry to cover the interval between
// the worker checking stopped and actually entering Read/WriteFile.
// These workers only issue anonymous-pipe IO, never arbitrary device IO.
// 取消状态不会自动作用于后续调用,因此需要重试,以覆盖工作线程检查停止状态到实际进入
// ReadFile/WriteFile 之间的窗口。这些线程只操作匿名管道,不访问任意设备。
while !thread.is_finished() {
unsafe {
CancelSynchronousIo(thread.as_raw_handle());
@@ -185,8 +184,7 @@ impl Pump {
&pump.state,
"extension-stderr",
move |state| {
// Drain without persisting possible secrets. Diagnostic retention
// needs an explicit redaction policy before it can be enabled.
// 清空数据但不持久化可能的秘密;只有明确配置脱敏策略后才能保留诊断信息。
let mut buffer = [0; 4096];
let mut total = 0;
while !state.stopped.load(Ordering::Acquire) {
@@ -206,12 +204,11 @@ impl Pump {
)?);
Ok(pump)
}
/// Nonblocking admission; at most one pending write plus one in progress.
/// 非阻塞接收;最多允许一个等待写入和一个正在写入的请求。
pub fn send(&self, frame: Vec<u8>) -> Result<()> {
self.send_wait(frame, Duration::ZERO)
}
/// Bounded admission for serial protocol notifications immediately followed
/// by a request; retries retain the same frame, without allocating copies.
/// 为协议通知紧接请求的串行场景提供有界接收;重试保留同一帧,不分配副本。
pub(crate) fn send_wait(&self, mut frame: Vec<u8>, timeout: Duration) -> Result<()> {
self.state.check()?;
if timeout > Duration::from_secs(1) {
@@ -275,7 +272,7 @@ impl Pump {
pub fn check(&self) -> Result<()> {
self.state.check()
}
/// Stop the process group first, then cancel and join every pipe worker.
/// 先停止进程组,再取消并等待所有管道工作线程。
pub fn shutdown(mut self) -> Result<()> {
let result = self.state.job.terminate();
self.state.stopped.store(true, Ordering::Release);
@@ -364,8 +361,7 @@ mod tests {
let started = Instant::now();
pump.shutdown().unwrap();
assert!(started.elapsed() < Duration::from_secs(2));
// The peer handles remained open throughout shutdown. No process
// exit or peer EOF is available to mask broken IO cancellation.
// 关闭期间对端句柄始终保持打开,不能用进程退出或对端 EOF 掩盖失效的 IO 取消。
drop(peers);
}
}
+72 -17
View File
@@ -3,6 +3,7 @@ use crate::workspace::{HostError, Result};
use std::{
mem::size_of,
os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle},
path::{Path, PathBuf},
};
use windows_sys::Win32::System::{
JobObjects::*,
@@ -26,9 +27,15 @@ impl Job {
})
}
pub fn new() -> Result<Self> {
Self::with_process_limit(16)
Self::with_process_limit(16, None)
}
fn with_process_limit(processes: u32) -> Result<Self> {
pub fn with_scratch(scratch: &Path) -> Result<Self> {
if !scratch.is_absolute() {
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
}
Self::with_process_limit(16, Some(scratch.to_owned()))
}
fn with_process_limit(processes: u32, scratch: Option<PathBuf>) -> Result<Self> {
let raw = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if raw.is_null() {
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
@@ -63,7 +70,7 @@ impl Job {
},
};
job.set(JobObjectCpuRateControlInformation, &cpu)?;
job._monitor = Some(ResourceMonitor::arm(&job)?);
job._monitor = Some(ResourceMonitor::arm(&job, scratch)?);
Ok(job)
}
pub fn check_resources(&self) -> Result<()> {
@@ -74,6 +81,7 @@ impl Job {
3 => Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED")),
4 => Err(HostError::new("EXTENSION_RESOURCE_MEMORY_EXCEEDED")),
5 => Err(HostError::new("EXTENSION_RESOURCE_PROCESSES_EXCEEDED")),
6 => Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED")),
_ => Err(HostError::new("EXTENSION_RESOURCE_MONITOR_FAILED")),
}
}
@@ -93,7 +101,7 @@ impl Job {
}
/// 在任何扩展指令执行之前附加。没有启用任何分离标志。
///
/// # 安全性
/// # Safety
/// 调用方必须拥有尚未恢复执行的 CREATE_SUSPENDED 进程,并在出现任何错误时终止该进程。
/// 只有 AppContainer、句柄与权限检查全部通过后,才能恢复执行。
pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> {
@@ -129,18 +137,18 @@ 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.
/// Windows 通知使用十秒窗口和 ToleranceHigh(允许超出预算 60%),不表示已经
/// 测得连续十秒满载。只有原始 Job 拥有监视器,观察和期限副本不拥有。
const JOB_MEMORY_LIMIT: u32 = 10; // JOB_OBJECT_MSG_JOB_MEMORY_LIMIT
const JOB_PROCESS_LIMIT: u32 = 3; // JOB_OBJECT_MSG_ACTIVE_PROCESS_LIMIT
const JOB_NOTIFICATION_LIMIT: u32 = 11; // JOB_OBJECT_MSG_NOTIFICATION_LIMIT (Windows SDK)
const SCRATCH_LIMIT_BYTES: u64 = 256 * 1024 * 1024;
struct ResourceMonitor {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl ResourceMonitor {
fn arm(job: &Job) -> Result<Self> {
fn arm(job: &Job, scratch: Option<PathBuf>) -> Result<Self> {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
@@ -178,10 +186,16 @@ impl ResourceMonitor {
let worker = std::thread::Builder::new()
.name("extension-resources".into())
.spawn(move || {
// All exits, including a caught panic or completion-port failure,
// terminate the tree while this worker still owns a Job handle.
// 所有退出路径(包括捕获到的 panic 或完成端口错误)都会在本线程
// 仍持有 Job 句柄时终止整个进程树。
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while !thread_stop.load(Ordering::Acquire) {
if scratch
.as_deref()
.is_some_and(|path| scratch_usage(path).is_err())
{
return 6;
}
let (mut code, mut key, mut pointer) = (0, 0, std::ptr::null_mut());
let ok = unsafe {
GetQueuedCompletionStatus(
@@ -203,8 +217,8 @@ impl ResourceMonitor {
if key != 1 {
return 2;
}
// These hard-limit notifications are best effort on Windows;
// the kernel still enforces the configured allocation caps.
// 这些硬上限通知在 Windows 上是尽力投递;即使通知丢失,
// 内核仍执行已配置的分配上限。
if code == JOB_MEMORY_LIMIT {
return 4;
}
@@ -246,6 +260,48 @@ impl ResourceMonitor {
})
}
}
fn scratch_usage(root: &Path) -> Result<u64> {
if !root.is_absolute() {
return Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"));
}
let mut total = 0u64;
let mut entries = 0usize;
let mut pending = vec![root.to_owned()];
while let Some(path) = pending.pop() {
let metadata = std::fs::symlink_metadata(&path)
.map_err(|_| HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"))?;
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 || metadata.file_type().is_symlink() {
return Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"));
}
entries += 1;
if entries > 10_000 {
return Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"));
}
if metadata.is_dir() {
for child in std::fs::read_dir(&path)
.map_err(|_| HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"))?
{
pending.push(
child
.map_err(|_| HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"))?
.path(),
);
}
} else if metadata.is_file() {
total = total
.checked_add(metadata.len())
.ok_or_else(|| HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"))?;
if total > SCRATCH_LIMIT_BYTES {
return Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"));
}
} else {
return Err(HostError::new("EXTENSION_RESOURCE_SCRATCH_EXCEEDED"));
}
}
Ok(total)
}
impl Drop for ResourceMonitor {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Release);
@@ -440,8 +496,7 @@ mod tests {
);
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);
@@ -530,7 +585,7 @@ mod tests {
}
#[test]
fn actual_suspended_process_assignment_limits_and_close_cleanup() {
let job = Job::with_process_limit(1).unwrap();
let job = Job::with_process_limit(1, None).unwrap();
let first = worker();
unsafe {
job.assign_suspended(first.process.as_handle()).unwrap();
@@ -538,8 +593,8 @@ mod tests {
assert_eq!(job.active_processes().unwrap(), 1);
let second = worker();
assert!(unsafe { job.assign_suspended(second.process.as_handle()) }.is_err());
// Both processes were still suspended. The attempted limit violation
// now revokes the entire first job, rather than leaving it runnable.
// 两个进程仍处于暂停状态。触发上限后撤销整个首个 Job,不能把旧进程
// 留在可运行状态。
drop(second);
assert_resource_cleanup(&job, "EXTENSION_RESOURCE_PROCESSES_EXCEEDED");
assert_eq!(
@@ -80,7 +80,8 @@ impl PreparedLaunch {
}
}
impl<'a> LeasedSuspended<'a> {
/// # Safety Live 信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。
/// # Safety
/// 实时信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
unsafe { self.process.resume_with_lease(self.lease, self.identity) }
}
@@ -93,8 +94,7 @@ impl Drop for EnvironmentValues {
}
}
}
/// Credential setup and execution must use the same derived identity. The
/// reference is an opaque ID inside this package's domain, never a caller scope.
/// 凭据设置和执行必须使用同一派生身份。引用是该包域内的不透明 ID,不能充当调用方作用域。
pub fn credential_id(claims: &Claims, reference: &str) -> Result<CredentialId> {
if reference.is_empty()
|| reference.len() > 128
@@ -132,8 +132,7 @@ pub fn credential_id(claims: &Claims, reference: &str) -> Result<CredentialId> {
})
}
impl Context<'_> {
/// Capture epochs before resolving credentials; never adopt a newer lock
/// generation for launch bytes prepared under an earlier session.
/// 解析凭据前捕获代际;为旧会话准备的启动数据不得采用较新的锁代际。
pub fn prepare(
&self,
authority: &Authority,
@@ -205,7 +204,7 @@ impl Context<'_> {
.ok_or_else(|| HostError::new("EXTENSION_CREDENTIAL_MISSING"))?;
let value = std::str::from_utf8(&value)
.map_err(|_| HostError::new("EXTENSION_CREDENTIAL_ENCODING_INVALID"))?;
// Insert directly into the cleaning owner, never an error or log.
// 直接写入负责清零的所有者,绝不写入错误或日志。
values.0.insert(name.clone(), value.to_owned());
}
}
@@ -1,11 +1,16 @@
//! 本机 ​​argv/环境编码。这不会授权或启动进程。
use crate::workspace::{HostError, Result};
use std::{collections::BTreeMap, os::windows::ffi::OsStrExt, path::Path};
use std::{
collections::BTreeMap,
os::windows::ffi::OsStrExt,
path::{Path, PathBuf},
};
use zeroize::Zeroize;
pub struct LaunchData {
command: Vec<u16>,
environment: Vec<u16>,
scratch: PathBuf,
}
impl Drop for LaunchData {
fn drop(&mut self) {
@@ -40,6 +45,7 @@ impl LaunchData {
let mut result = Self {
command: Vec::with_capacity(32767),
environment: Vec::with_capacity(32767),
scratch: scratch.to_owned(),
};
result.command.push(34);
result.command.extend(executable);
@@ -91,13 +97,17 @@ impl LaunchData {
if result.command.len() > 32767 {
return Err(bad());
}
// AppContainer 会把用户 LocalAppData 下的逻辑路径重定向到配置文件的 AC 目录。
// 直接把物理 AC 路径交给子进程会被再次重定向,形成 AC\Packages\...\AC 的错误路径。
let (visible_local_app_data, visible_scratch) =
visible_container_paths(local_app_data, scratch);
// ASCII 名称给出确定性的 Windows 不区分大小写的顺序。值在编码之前一直是借用的,因此不存在秘密克隆。
let mut fields: BTreeMap<String, &std::ffi::OsStr> = BTreeMap::new();
for (name, path) in [
("SYSTEMROOT", system_root),
("LOCALAPPDATA", local_app_data),
("TEMP", scratch),
("TMP", scratch),
("LOCALAPPDATA", visible_local_app_data.as_path()),
("TEMP", visible_scratch.as_path()),
("TMP", visible_scratch.as_path()),
] {
if !path.is_absolute() {
return Err(bad());
@@ -146,6 +156,29 @@ impl LaunchData {
pub fn environment(&self) -> &[u16] {
&self.environment
}
pub(crate) fn scratch(&self) -> &Path {
&self.scratch
}
}
fn visible_container_paths(local_app_data: &Path, scratch: &Path) -> (PathBuf, PathBuf) {
let is_ac = local_app_data
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("AC"));
let is_package = local_app_data
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("Packages"));
if is_ac && is_package {
if let (Some(base), Ok(relative)) = (
local_app_data.ancestors().nth(3),
scratch.strip_prefix(local_app_data),
) {
return (base.to_owned(), base.join(relative));
}
}
(local_app_data.to_owned(), scratch.to_owned())
}
#[cfg(test)]
@@ -220,4 +253,13 @@ mod tests {
assert!(data.environment().contains(&0xd800));
assert!(data.environment().ends_with(&[0, 0]));
}
#[test]
fn converts_physical_appcontainer_paths_to_child_visible_paths() {
let (local, scratch) = visible_container_paths(
Path::new(r"C:\Users\tester\AppData\Local\Packages\OpenNexus.sandbox.id\AC"),
Path::new(r"C:\Users\tester\AppData\Local\Packages\OpenNexus.sandbox.id\AC\Temp"),
);
assert_eq!(local, Path::new(r"C:\Users\tester\AppData\Local"));
assert_eq!(scratch, Path::new(r"C:\Users\tester\AppData\Local\Temp"));
}
}
+5 -6
View File
@@ -78,7 +78,7 @@ fn decode(bytes: &[u8]) -> Result<Envelope> {
{
return Err(invalid());
}
// Error data is never propagated or logged; it may contain secrets.
// 错误数据可能包含秘密,因此绝不传播或记录。
let _ = &error.data;
}
Ok(value)
@@ -217,8 +217,8 @@ impl<'a, 'p> Session<'a, 'p> {
.tool(name)?;
self.calls.review(&tool, arguments)
}
/// Only invoke from an authenticated Host route after the user approved this
/// exact review. This method is not registered as a renderer/Core command.
/// 仅在用户批准完全一致的审查后由已认证 Host 路由调用。
/// 此方法不会注册为 renderer/Core 命令。
pub fn confirm_call(
&mut self,
review_id: &str,
@@ -312,8 +312,7 @@ impl<'a, 'p> Session<'a, 'p> {
}
Ok(true)
}
/// Apply already-received notifications before selecting a cached contract.
/// The eventual registry loop must also call this while the instance is idle.
/// 选择缓存契约前应用已收到的通知;最终注册表循环在实例空闲时也必须调用此方法。
pub fn drain_pending(&mut self) -> Result<()> {
let result = (|| {
for _ in 0..128 {
@@ -373,7 +372,7 @@ impl<'a, 'p> Session<'a, 'p> {
self.process.check_authorization()?;
deadline.check()?;
if cancel.load(Ordering::Acquire) || started.elapsed() >= budget {
// initialize cannot be cancelled at the protocol level.
// initialize 无法在协议层取消。
if method != "initialize" {
let _ = self.send(json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":id}}));
}
+2 -2
View File
@@ -35,8 +35,8 @@ impl Drop for PackageAccess<'_> {
}
}
}
/// The package borrow and all ancestor handles must outlive the process using
/// this path. Only files present in the verified package can produce this guard.
/// 包借用和所有祖先句柄的生命周期必须长于使用此路径的进程。
/// 只有已验证包中存在的文件才能生成此守卫。
pub struct BoundEntry<'a> {
name: String,
path: std::path::PathBuf,
+31 -12
View File
@@ -96,9 +96,8 @@ pub struct Running<'a> {
identity: Option<crate::extension_call_authorization::Identity>,
}
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.
/// 使用显式环境和工作目录创建隐藏进程,不继承任意句柄。Profile 借用会在
/// 所有者存活期间阻止清理;此 API 永远不会恢复扩展指令。
pub fn create(profile: &'a Profile, executable: &Path, data: LaunchData) -> Result<Self> {
Self::create_inner(profile, executable, data, None)
}
@@ -118,6 +117,28 @@ impl<'a> Suspended<'a> {
}
let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect();
let folder = profile.folder()?;
std::fs::create_dir_all(data.scratch())
.map_err(|_| HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"))?;
let scratch_metadata = std::fs::symlink_metadata(data.scratch())
.map_err(|_| HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"))?;
use std::os::windows::fs::MetadataExt;
if !scratch_metadata.is_dir()
|| scratch_metadata.file_attributes() & 0x400 != 0
|| scratch_metadata.file_type().is_symlink()
{
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
}
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, READ_CONTROL, WRITE_DAC,
};
let scratch_handle = std::fs::OpenOptions::new()
.access_mode(READ_CONTROL | WRITE_DAC)
.share_mode(1 | 2 | 4)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(data.scratch())
.map_err(|_| HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"))?;
profile.grant_scratch_modify(&scratch_handle)?;
let directory: Vec<u16> = folder.as_os_str().encode_wide().chain(Some(0)).collect();
let mut attributes = Attributes::new(if io.is_some() { 2 } else { 1 })?;
let caps = SECURITY_CAPABILITIES {
@@ -140,12 +161,12 @@ impl<'a> Suspended<'a> {
{
return Err(HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED"));
}
let job = Job::new()?;
let job = Job::with_scratch(data.scratch())?;
let mut startup = STARTUPINFOEXW::default();
startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() 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.
// 在 CreateProcessW 返回前同时保留句柄数组和管道所有者,不允许任意
// Host 可继承句柄进入扩展进程。
let io = io
.map(crate::extension_stdio::ChildIo::inherit)
.transpose()?;
@@ -209,8 +230,7 @@ impl<'a> Suspended<'a> {
verify_identity(&process.handles, profile)?;
Ok(Self(process))
}
/// Package launch path: retain the entry guard (and its package/ancestor
/// handles) for the entire suspended/running process lifetime.
/// 包启动路径在暂停和运行的整个生命周期内保留入口守卫及包祖先句柄。
#[cfg(feature = "desktop")]
pub fn create_bound(
profile: &'a Profile,
@@ -234,10 +254,9 @@ impl<'a> Suspended<'a> {
Ok((value, host))
}
/// # Safety
/// Caller must hold the verified package/entry handles and revalidate the
/// current execution permit, trust, Vault binding, environment declarations,
/// broker and all resource policy requirements immediately before this call.
/// None of those authorization checks is supplied by this low-level module.
/// 调用方必须持有已验证的包与入口句柄,并在调用前立即复核当前执行许可、信任、
/// Vault 绑定、环境声明、broker 以及全部资源策略要求。
/// 此底层模块不会代为执行上述授权检查。
pub unsafe fn resume(self) -> Result<Running<'a>> {
self.0.job.check_resources()?;
if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 {
+3 -5
View File
@@ -90,9 +90,8 @@ impl InheritedIo {
}
}
/// 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.
/// NDJSON 上限不含行结束符。协议或 IO 错误会使解码器失效;运行时必须终止实例并关闭管道。
/// 此同步解码器需要独立的 IO 取消和期限所有者。
pub const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024;
pub struct Frames<R> {
reader: R,
@@ -206,8 +205,7 @@ mod tests {
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
worker.join().unwrap();
// The last writer was closed before the lock was released; no child
// process was launched in this ownership test, so Host sees EOF.
// 最后一个写端在释放锁前关闭;此所有权测试没有启动子进程,因此 Host 会收到 EOF。
use std::io::Read;
let mut output = host.output;
assert_eq!(output.read(&mut [0; 1]).unwrap(), 0);
+1 -1
View File
@@ -26,7 +26,7 @@ pub struct Checked {
archive_path: String,
}
impl Checked {
/// Monotonic freshness avoids a wall-clock rollback extending validity.
/// 使用单调时钟判断新鲜度,避免系统时钟回拨延长有效期。
pub fn matches(&self, source: &str, release: &Release, key: &[u8; 32]) -> Result<()> {
if self.checked_at.elapsed() > Duration::from_secs(30) {
return Err(HostError::new("EXTENSION_TRUST_STALE"));
+2 -2
View File
@@ -304,7 +304,7 @@ mod core_proxy_tests {
}
}
/// Authenticated process-local transport; session headers are owned by Rust.
/// 经过认证的进程本地传输;会话请求头由 Rust 管理。
#[tauri::command]
fn core_request_prepare(host: State<'_, Host>, timeout_ms: u64) -> Result<String, String> {
host.requests.prepare(timeout_ms)
@@ -966,7 +966,7 @@ fn main() {
}
});
let data_dir = app.path().app_data_dir()?.join("core-data");
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
// 调试构建使用当前工作树的解释器;发布构建只使用随包提供的 Core
let core = if cfg!(debug_assertions) {
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
+1 -1
View File
@@ -89,7 +89,7 @@ impl Lease {
&mut self,
operation: impl Future<Output = Result<T, String>>,
) -> Result<T, String> {
// Check current state before polling an operation with possible side effects.
// 轮询可能产生副作用的操作前先检查当前状态。
if *self.cancel.borrow() {
return Err("REQUEST_CANCELLED".into());
}
+1 -1
View File
@@ -199,7 +199,7 @@ pub async fn sync_bind(host: State<'_, Host>, request: Bind) -> Result<Binding,
.await
.map_err(|e| e.code)?;
} else if request.mode == "download" {
// Verify account ownership before creating the durable binding.
// 创建持久绑定前验证账号所有权。
let vaults = sync_auth::guarded(
&host.credentials,
client.json(reqwest::Method::GET, "sync/v1/vaults", None),
+4 -5
View File
@@ -590,7 +590,7 @@ impl Workspace {
}
let mut previous = self.entry(path)?;
if let Some(id) = identity {
// Tombstone metadata can yield its old path to a new remote identity.
// 墓碑元数据可以把旧路径让给新的远端身份。
if let Some(retired) = previous
.as_ref()
.filter(|entry| entry.deleted && entry.file_id != id)
@@ -651,8 +651,7 @@ impl Workspace {
origin
],
)?;
// Rejection drops the uncommitted transaction: no recoverable write or
// outbox entry is published. An unreferenced payload is never replayed.
// 拒绝会丢弃未提交事务:不会发布可恢复写入或 outbox 条目,也不会重放无引用载荷。
authorize()?;
tx.commit()?;
self.apply_stored_journal(operation_id, &file_id, path, expected, origin)?;
@@ -1019,7 +1018,7 @@ impl Workspace {
tx.commit()?;
return Err(HostError::new("RECOVERY_CONFLICT"));
}
// The durable payload remains available after removing the source.
// 删除来源后,持久载荷仍保持可用。
if !target.exists() {
let parent = target
.parent()
@@ -1612,7 +1611,7 @@ mod tests {
.unwrap();
assert_eq!(committed.revision, initial.revision + 1);
assert_eq!(ws.pending_count().unwrap(), pending + 1);
// Revoked callers cannot obtain an existing successful receipt either.
// 已撤销的调用方也不能取得已有的成功回执。
assert_eq!(
ws.write_operation_guarded("note.md", &initial.hash, b"update", &id, || Err(
HostError::new("EXTENSION_PERMIT_REVOKED")
@@ -59,6 +59,13 @@ fn main() {
for mut child in children { let _ = child.kill(); let _ = child.wait(); }
return;
}
if args[1] == "mcp_scratch" {
let scratch = std::path::PathBuf::from(std::env::var_os("TEMP").unwrap());
let file = std::fs::File::create(scratch.join("quota-probe.bin")).unwrap();
file.set_len(300 * 1024 * 1024).unwrap();
std::thread::sleep(Duration::from_secs(120));
return;
}
if args[1] == "mcp_cancel" || args[1] == "mcp_deadline" || args[1] == "mcp_cpu" {
let _child = std::process::Command::new(std::env::current_exe().unwrap()).arg(if args[1] == "mcp_cpu" { "cpu_burn" } else { "wait" }).spawn().unwrap();
std::thread::sleep(Duration::from_secs(120));
@@ -25,17 +25,15 @@ function reconcile(view: EditorView) {
if (view.state.doc.rangeHasMark(start, end, mark)) return
const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1)
tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create())
// Filling an existing pair must keep subsequent letters inside code. Typing
// the closing delimiter explicitly should instead leave code as usual.
// 补全已有成对标记时,后续字母必须留在行内代码中;显式输入结束标记则应像通常一样离开代码范围。
if ($from.pos < end) tr.setStoredMarks([mark.create()])
else tr.removeStoredMark(mark)
view.dispatch(tr)
}
// DOM input can bypass handleTextInput (IME, replacement text, missing event.data).
// Observe it in capture phase, then wait for ProseMirror's DOM observer and its
// composition cleanup before inspecting the document. Never reconcile on load,
// paste, undo, or a selection change alone.
// DOM 输入可能绕过 handleTextInput(输入法、替换文本或缺少 event.data)。
// 在捕获阶段观察输入,等待 ProseMirror DOM 观察器和组合输入清理完成后再检查文档。
// 加载、粘贴、撤销或仅改变选区时不执行协调。
export const inlineCodeInputPlugin = $prose(() => new Plugin({
view(view) {
let timer: ReturnType<typeof setTimeout> | undefined
@@ -67,7 +65,7 @@ export const inlineCodeInputPlugin = $prose(() => new Plugin({
const start = () => { composing = true; cancel() }
const end = () => { composing = false; pending = true; attempts = 0; schedule() }
const keydown = (event: KeyboardEvent) => {
// ProseMirror handles undo/paste itself, so those actions need not emit input.
// ProseMirror 会自行处理撤销和粘贴,因此这些操作无需触发 input
if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel()
else if (pending && !composing && !view.composing && event.key === 'Enter') {
cancel()
+4 -5
View File
@@ -82,7 +82,7 @@ async function loadCodeLanguage(requestedLanguage: string) {
return { shiki, language }
}
// Bounded LRU of dual-theme HTML. Large one-off blocks never remain in the cache.
// 双主题 HTML 使用有界 LRU;大型一次性代码块不会留在缓存中。
const highlightedBlocks = new Map<string, string>()
let highlightedCharacters = 0
const highlightBudget = 1_000_000
@@ -101,7 +101,7 @@ export async function highlightCode(source: string, requestedLanguage = 'text'):
})
const cost = key.length + html.length
if (cost <= highlightBudget / 4) {
// A concurrent caller may already have filled the same entry.
// 并发调用方可能已经填充同一条目。
const previous = highlightedBlocks.get(key)
if (previous !== undefined) { highlightedCharacters -= key.length + previous.length; highlightedBlocks.delete(key) }
while (highlightedBlocks.size && (highlightedBlocks.size >= 64 || highlightedCharacters + cost > highlightBudget)) {
@@ -114,7 +114,7 @@ export async function highlightCode(source: string, requestedLanguage = 'text'):
return html
}
/** Share the initialized grammar/theme registry with editable code blocks. */
/** 与可编辑代码块共享已初始化的语法和主题注册表。 */
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', requestedLanguage = 'text') {
const { shiki } = await loadCodeLanguage(requestedLanguage)
return (source: string, requestedLanguage: string) => {
@@ -158,8 +158,7 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
}
const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
const fragment = document.createRange().createContextualFragment(highlighted)
// Shiki separates line spans with newlines. Block layout must not render those
// separators as additional blank rows; the untouched source remains available for copy.
// Shiki 用换行符分隔行 span。块布局不能把分隔符渲染成额外空行;复制时仍使用未改动的源码。
for (const node of [...(fragment.querySelector('code')?.childNodes ?? [])]) {
if (node.nodeType === Node.TEXT_NODE && !node.textContent?.trim()) node.remove()
}
+2 -3
View File
@@ -17,8 +17,7 @@ window.prepareScrollBenchmark = async (size = 1000) => {
document.documentElement.dataset.theme=theme
const style=document.createElement('style');style.textContent=theme==='paper-moments'?getCommunityThemePreviewCss(theme):'';document.head.append(style)
const pinia=createPinia(), store=useTaskStore(pinia), host=document.getElementById('app'), viewport=document.getElementById('viewport')
// App tokens normally clip #app; the real Agent page provides its own scroller.
// This standalone Trace mount uses #viewport in that role.
// 应用样式通常会裁剪 #app;真实 Agent 页面自带滚动容器,此独立 Trace 挂载改用 #viewport。
if(kind==='trace'){host.style.height='auto';host.style.overflow='visible'}
const date='2026-09-06T00:00:00Z'
const tasks=Array.from({length:size},(_,i)=>({task_id:`task_${i}`,title:`压测任务 ${i}`,description:'用于验证任务列表渲染与筛选,独立生成,不读取真实笔记。',status:i%3===0?'done':'todo',created_at:date,updated_at:date}))
@@ -27,7 +26,7 @@ window.prepareScrollBenchmark = async (size = 1000) => {
return {run_id:'stress',sequence:i,event,timestamp:new Date(Date.parse(date)+i*10).toISOString(),data:{step,model_call_id:`m_${step}`,parent_model_call_id:`m_${step}`,tool_call_id:`t_${step}`,name:'system.echo',arguments:{text:`压力测试 ${step}`},output:{text:'工具返回内容'},success:true,duration_ms:10}}
}))
const originalFetch=window.fetch, requests=[]
// Intercept every request in this isolated page: never fall through to the user's backend.
// 拦截此隔离页面的每个请求,绝不转发到用户后端。
window.fetch=async (input)=>{
const url=new URL(typeof input==='string'?input:input.url,location.href);requests.push(url.pathname+url.search)
if(url.pathname==='/api/tasks'){