feat: 完成扩展资源验收与同步服务部署
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# OpenNexus Server Sync 阶段一部署记录
|
||||
|
||||
部署时间:2026-09-10
|
||||
|
||||
部署类型:阶段一明文 HTTP 测试
|
||||
|
||||
代码提交:`d703ab64e3f483dde9e5153c55b986d358f4e37d`
|
||||
|
||||
## 访问入口
|
||||
|
||||
- 控制台:`http://160.202.254.170:18080/`
|
||||
- 健康检查:`http://160.202.254.170:18080/health`
|
||||
- 依赖就绪:`http://160.202.254.170:18080/ready`
|
||||
- 协议握手:`http://160.202.254.170:18080/sync/v1/handshake?protocol=1`
|
||||
|
||||
该环境按测试要求直接监听 `0.0.0.0:18080`,没有修改现有 Nginx,也没有配置
|
||||
HTTPS。正式部署仍须恢复本机监听并由 TLS 反向代理提供外部入口。
|
||||
|
||||
## 服务组成
|
||||
|
||||
Docker Compose 项目名为 `opennexus-stage1`,包含 PostgreSQL 17.6、MinIO、
|
||||
一次性初始化任务和两个 Uvicorn worker 的 Sync 服务。长期服务使用独立的
|
||||
Bucket 限权账号,不使用 MinIO 管理员身份。数据库、对象存储和暂存区均使用
|
||||
独立具名卷。
|
||||
|
||||
目标机可用内存较少,因此阶段一部署为容器设置以下上限:
|
||||
|
||||
| 服务 | 内存上限 | CPU 上限 |
|
||||
| --- | ---: | ---: |
|
||||
| PostgreSQL | 256 MiB | 1 |
|
||||
| MinIO | 320 MiB | 1 |
|
||||
| 初始化任务 | 256 MiB | 1 |
|
||||
| Sync | 384 MiB | 1 |
|
||||
|
||||
## 已执行验证
|
||||
|
||||
- `/health` 返回 `status=ok`。
|
||||
- `/ready` 返回 `status=ready`。
|
||||
- 握手返回协议版本 1、100 MiB 对象上限和 1 MiB 分块大小。
|
||||
- 根路径返回构建后的 Vue 3 + TypeScript Sync Console。
|
||||
- 从部署机外部完成登录、创建并列出 Vault、退出登录的公开 API 闭环。
|
||||
- 验证结束后删除临时 Vault、设备与会话,仅保留阶段一演示账号。
|
||||
- 验证时 Sync、PostgreSQL、MinIO 的内存用量分别约为 214 MiB、27 MiB、
|
||||
135 MiB,均低于容器上限。
|
||||
|
||||
部署凭据只保存在目标机权限受限的 `.env` 中,本文和验收日志不记录口令、
|
||||
访问令牌、数据库密码或对象存储密钥。
|
||||
@@ -953,3 +953,20 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 提交 `ba7414e` 后构建 release Host 与 1523 文件的 PyInstaller Core。便携交付目录为 `H:\OpenNexus-demo\OpenNexus-0.3.0-alpha.1-portable-ba7414e\`,总计 274.3 MiB;Core 全部文件与 SHA-256 清单一致,`OpenNexus.exe` 摘要为 `E6136C61CAA910CD0006EF9FD94955B47FCF561ABF3FB217097AE36317394023`。实际启动后 Host 窗口保持响应,Core 子进程从便携目录运行。
|
||||
- 构建完成后删除工作树中的 `.build`、Rust `target`、前端 `dist`、Sync Console `node_modules` 和 TypeScript 构建缓存,G 盘剩余空间回升到 49.62 GiB。用户 Vault 的现有修改未纳入提交、未移动也未删除。
|
||||
- 测试地址 `http://yui.kronecker.cc:18080` 当前仍返回空响应,SSH 连接在认证前由对端关闭,因此本次未能把控制台发布到该测试端口;本地 Demo 与可部署源码、静态产物均已完成。其余 12 项长时生产验收继续按用户要求暂缓。
|
||||
|
||||
|
||||
## 增量:Server Sync 阶段一远端部署
|
||||
|
||||
- 使用新的测试服务器完成 Server Sync 全栈部署,公开入口为 `http://160.202.254.170:18080/`。按阶段一要求直接监听 HTTP,没有修改 Nginx 或配置 HTTPS;根路径发布 Vue 3 + TypeScript Sync Console。
|
||||
- Docker Compose 运行 PostgreSQL 17.6、MinIO、一次性初始化任务和两个 Uvicorn worker。Compose 新增可配置的 `SYNC_BIND_ADDRESS` 与 `SYNC_PORT`,默认仍只绑定 `127.0.0.1:8080`,只有测试部署显式使用 `0.0.0.0:18080`。
|
||||
- 外部验证 `/health`、`/ready`、协议握手、登录、创建与列出 Vault、退出登录全部成功。验证后已删除临时 Vault、设备和会话,只保留阶段一演示账号;数据库计数为 1 个用户、0 个 Vault、0 个会话。
|
||||
- 目标机内存较小,部署为 PostgreSQL、MinIO、初始化任务和 Sync 分别设置 256、320、256、384 MiB 上限。验证时三项长期服务约使用 27、135、214 MiB。部署说明见 `docs/deployment/Server Sync阶段一部署-2026-09-10.md`,不包含口令或令牌。
|
||||
|
||||
|
||||
## 增量:C-04 扩展资源与工具期限生产验收
|
||||
|
||||
- 为每个原生扩展实例增加 256 MiB scratch 上限。Host 每 100 ms 从实例专用物理目录重新遍历,拒绝重解析点、符号链接、非普通对象、超过 10000 个条目或超过配额;触发后返回 `EXTENSION_RESOURCE_SCRATCH_EXCEEDED` 并终止整个 Job 进程树。
|
||||
- 发现 AppContainer 会把传入的物理 `Packages/<实例>/AC` 路径再次虚拟化,导致子进程 TEMP 出现双重 `AC/Packages/.../AC` 路径。现在启动数据为子进程提供 LocalAppData 下的逻辑路径,Host 继续持有并监控真实 scratch 路径,同时通过实例 SID 授予目录继承写权限。
|
||||
- C-04 driver 固定执行四个精确 Rust Host oracle。真实后台 MCP 分别触发 CPU、512 MiB 内存、16 进程和 256 MiB scratch 上限,全部返回对应错误、清空进程树并能启动替代实例;scratch 超限本轮约 92 ms 可见。真实 60 秒工具期限在 60.015 秒左右回收两级进程树,期限前后 Host 均成功保存并重开本地笔记。文件 broker 第 33 个同秒请求返回 `EXTENSION_BROKER_RATE_LIMITED`。
|
||||
- 直接 driver 结果为 `PASSED`,四组断言全部通过。严格 Clippy `-D warnings` 通过;desktop Rust 全量回归 146 通过、0 失败、14 ignored,总耗时 744 秒;runner 7 项、Server Sync 30 项、前端类型检查和 542 项前端测试均通过。前端测试出现一次对未启动 `localhost:3000` 的预期连接拒绝诊断,但测试进程仍以 104 个文件、542 项全部通过结束。
|
||||
- 测试依赖和断链目录在验证后从 G/H 盘清理,G 盘可用空间约 49.95 GiB。C-04 可记为通过,正式验收累计 19/30;A-01/A-04、C-01/C-02、D-02/D-04 与 E-01~E-05 尚未通过,不能据此声明完整第三阶段生产验收完成。
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'){
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""C-04:Windows 扩展资源配额、期限、回收与 broker 限流验收驱动。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml"
|
||||
TESTS = (
|
||||
"extension_instance::tests::native_resource_failures_are_reaped_and_replacements_can_start",
|
||||
"extension_container::tests::real_sixty_second_tool_deadline_kills_tree_and_host_can_save",
|
||||
"extension_file_broker::tests::permissions_scope_limits_hardlinks_and_revocation_fail_closed",
|
||||
"extension_job::tests::production_limits_are_configured_and_explicit_termination_reaps_process",
|
||||
)
|
||||
IGNORED = frozenset(TESTS[:2])
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_test(cargo: str, test: str) -> bool:
|
||||
command = [
|
||||
cargo,
|
||||
"test",
|
||||
"--manifest-path",
|
||||
str(MANIFEST),
|
||||
"--locked",
|
||||
"--features",
|
||||
"desktop",
|
||||
"--lib",
|
||||
test,
|
||||
"--",
|
||||
]
|
||||
if test in IGNORED:
|
||||
command.append("--ignored")
|
||||
command.extend(["--exact", "--nocapture"])
|
||||
environment = dict(os.environ)
|
||||
environment.setdefault(
|
||||
"CARGO_TARGET_DIR",
|
||||
str(Path(environment["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]) / "cargo-target"),
|
||||
)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
transcript = completed.stdout + completed.stderr
|
||||
print(transcript, end="")
|
||||
return completed.returncode == 0 and "1 passed; 0 failed" in transcript
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
|
||||
cargo = shutil.which(os.environ.get("CARGO", "cargo"))
|
||||
passed = case_id == "C-04" and os.name == "nt" and cargo is not None
|
||||
if passed:
|
||||
passed = all(run_test(cargo, test) for test in TESTS)
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
payload = {
|
||||
"schema": 1,
|
||||
"case_id": case_id,
|
||||
"status": status,
|
||||
"reason": "" if passed else "至少一个 C-04 真实进程或 broker 验收断言失败。",
|
||||
"assertions": [
|
||||
{
|
||||
"name": name,
|
||||
"status": status,
|
||||
"evidence": "四个精确 Rust Host 测试,包含真实 AppContainer 子进程和本地 Workspace 保存",
|
||||
}
|
||||
for name in (
|
||||
"512 MiB 内存、256 MiB scratch、16 进程与 CPU 超限均返回明确错误",
|
||||
"每次资源失败均清空进程树并可创建替代实例",
|
||||
"60 秒工具期限到达后回收两级进程树且 Host 可保存并重开笔记",
|
||||
"broker 每秒第 33 个请求返回 EXTENSION_BROKER_RATE_LIMITED",
|
||||
)
|
||||
],
|
||||
"metrics": {
|
||||
"memory_limit_bytes": 512 * 1024 * 1024 if passed else 0,
|
||||
"scratch_limit_bytes": 256 * 1024 * 1024 if passed else 0,
|
||||
"process_limit": 16 if passed else 0,
|
||||
"tool_deadline_seconds": 60 if passed else 0,
|
||||
"cleanup_deadline_ms": 10_000 if passed else 0,
|
||||
"broker_requests_per_second": 32 if passed else 0,
|
||||
"resource_failures_verified": 5 if passed else 0,
|
||||
},
|
||||
"files": [
|
||||
{"path": relative, "sha256": sha256(ROOT / relative)}
|
||||
for relative in (
|
||||
"frontend/src-tauri/src/extension_job.rs",
|
||||
"frontend/src-tauri/src/extension_launch_data.rs",
|
||||
"frontend/src-tauri/src/extension_process.rs",
|
||||
"frontend/src-tauri/src/extension_file_broker.rs",
|
||||
"frontend/src-tauri/src/extension_instance.rs",
|
||||
"frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs",
|
||||
)
|
||||
],
|
||||
"revisions": [
|
||||
{"scope": "resource policy", "memory_mib": 512, "scratch_mib": 256, "processes": 16},
|
||||
{"scope": "tool deadline", "seconds": 60, "cleanup_seconds": 10},
|
||||
{"scope": "file broker", "requests_per_second": 32},
|
||||
],
|
||||
}
|
||||
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -90,6 +90,20 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
|
||||
),
|
||||
"platform_profiles": ("windows-11-x64",),
|
||||
},
|
||||
"C-04": {
|
||||
"driver": "scripts/acceptance_cases/c04_resources.py",
|
||||
"timeout_seconds": 900,
|
||||
"required_metrics": (
|
||||
"memory_limit_bytes",
|
||||
"scratch_limit_bytes",
|
||||
"process_limit",
|
||||
"tool_deadline_seconds",
|
||||
"cleanup_deadline_ms",
|
||||
"broker_requests_per_second",
|
||||
"resource_failures_verified",
|
||||
),
|
||||
"platform_profiles": ("windows-11-x64",),
|
||||
},
|
||||
"D-01": {
|
||||
"driver": "scripts/acceptance_cases/d01_extensions.py",
|
||||
"timeout_seconds": 900,
|
||||
|
||||
@@ -5,3 +5,6 @@ MINIO_ROOT_USER=
|
||||
MINIO_ROOT_PASSWORD=
|
||||
SYNC_ACCESS_KEY_ID=
|
||||
SYNC_SECRET_ACCESS_KEY=
|
||||
# 默认只监听本机;仅在已隔离的明文 HTTP 测试阶段显式改为 0.0.0.0。
|
||||
SYNC_BIND_ADDRESS=127.0.0.1
|
||||
SYNC_PORT=8080
|
||||
|
||||
@@ -25,7 +25,9 @@ uv run pytest
|
||||
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。
|
||||
2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号,不使用 MinIO root 身份。
|
||||
3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数。
|
||||
4. 使用 Caddy 示例配置 TLS。8080 仅绑定本机,不直接公开明文 HTTP。
|
||||
4. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 与
|
||||
`SYNC_PORT=8080` 只监听本机。仅限已授权的隔离测试阶段将监听地址改为
|
||||
`0.0.0.0` 并直接开放测试端口;该模式不作为生产发布配置。
|
||||
5. 检查 `/health`、`/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
|
||||
|
||||
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket,`.env` 中的 root 与同步凭据必须不同。
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
volumes:
|
||||
- staging:/staging
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "${SYNC_BIND_ADDRESS:-127.0.0.1}:${SYNC_PORT:-8080}:8080"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
|
||||
Reference in New Issue
Block a user