diff --git a/.gitea/workflows/windows-rc.yml b/.gitea/workflows/windows-rc.yml new file mode 100644 index 0000000..c5d808c --- /dev/null +++ b/.gitea/workflows/windows-rc.yml @@ -0,0 +1,113 @@ +name: Windows RC + +on: + workflow_dispatch: + +jobs: + signed-rc: + runs-on: windows-latest + permissions: + contents: read + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + components: rustfmt, clippy + + - name: 准备锁定依赖 + shell: pwsh + run: | + python -m pip install uv==0.9.24 + uv sync --frozen --group packaging --directory backend + corepack enable + corepack prepare pnpm@10.28.0 --activate + pnpm --dir frontend install --frozen-lockfile + + - name: 导入受控签名材料 + shell: pwsh + env: + WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + CORE_SIGNING_KEY_PEM_BASE64: ${{ secrets.CORE_SIGNING_KEY_PEM_BASE64 }} + run: | + if (-not $env:WINDOWS_CERTIFICATE_BASE64 -or -not $env:WINDOWS_CERTIFICATE_PASSWORD -or -not $env:CORE_SIGNING_KEY_PEM_BASE64) { + throw '缺少 Windows RC 签名秘密' + } + $secretRoot = Join-Path $env:RUNNER_TEMP 'opennexus-signing' + New-Item -ItemType Directory -Force -Path $secretRoot | Out-Null + $pfx = Join-Path $secretRoot 'codesign.pfx' + $coreKey = Join-Path $secretRoot 'core-ed25519.pem' + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)) + [IO.File]::WriteAllBytes($coreKey, [Convert]::FromBase64String($env:CORE_SIGNING_KEY_PEM_BASE64)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.HasPrivateKey) { throw '代码签名证书没有私钥' } + "OPENNEXUS_CORE_SIGNING_KEY_FILE=$coreKey" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + Remove-Item -LiteralPath $pfx -Force + + - name: 构建签名 Core + shell: pwsh + run: uv run --directory backend --group packaging python ../scripts/build-core.py --release + + - name: 生成签名打包配置 + shell: pwsh + run: | + $config = @{ + bundle = @{ + active = $true + targets = @('nsis') + resources = @{ + '../../.build/sidecar/dist/opennexus-core/' = 'core/' + } + windows = @{ + certificateThumbprint = $env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT + digestAlgorithm = 'sha256' + timestampUrl = 'http://timestamp.digicert.com' + } + } + } | ConvertTo-Json -Depth 5 + $path = Join-Path $env:GITHUB_WORKSPACE 'frontend\src-tauri\tauri.rc.conf.json' + [IO.File]::WriteAllText($path, $config, [Text.UTF8Encoding]::new($false)) + "OPENNEXUS_RC_CONFIG=$path" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + + - name: 构建 MSVC NSIS 安装包 + shell: pwsh + run: pnpm --dir frontend exec tauri build --target x86_64-pc-windows-msvc --features desktop --config src-tauri/tauri.rc.conf.json + + - name: 验证 RC 签名与大小 + shell: pwsh + run: ./scripts/verify-windows-rc.ps1 + + - uses: actions/upload-artifact@v4 + with: + name: OpenNexus-windows-x64-rc + if-no-files-found: error + retention-days: 14 + path: | + frontend/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + .build/sidecar/manifest.json + .build/sidecar/manifest.sig + .build/sidecar/public-key.hex + .build/windows-rc-sha256.json + + - name: 清理签名材料 + if: always() + shell: pwsh + run: | + if ($env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath (Join-Path $env:RUNNER_TEMP 'opennexus-signing') -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'frontend\src-tauri\tauri.rc.conf.json') -Force -ErrorAction SilentlyContinue diff --git a/backend/app/extensions/installed.py b/backend/app/extensions/installed.py index 7c43822..033624e 100644 --- a/backend/app/extensions/installed.py +++ b/backend/app/extensions/installed.py @@ -46,6 +46,15 @@ class InstalledRuntime: with self._db() as db: db.execute('CREATE TABLE IF NOT EXISTS installations (kind TEXT, id TEXT, data TEXT, PRIMARY KEY(kind,id))') + def _require_python_owner(self): + """Rust Host 接管安装库后,旧 Python 入口只能读取,不能再改变扩展状态。""" + if (self.path.parent / 'extension-installations.rust-owned.json').is_file(): + raise ExtensionError( + 'EXTENSION_HOST_OWNED', + 'Extension installation state is owned by the Rust Host.', + status_code=409, + ) + @contextmanager def _db(self): db = sqlite3.connect(self.path) @@ -82,6 +91,7 @@ class InstalledRuntime: def install(self, package_path, *, managed_root=None): with self.lock: + self._require_python_owner() root = Path(package_path).resolve() package_digest(root) # 更改运行时状态之前检查。 if managed_root is not None: @@ -100,6 +110,7 @@ class InstalledRuntime: def enable(self, identifier): with self.lock: + self._require_python_owner() # 必须重新安装更改的软件包以重新解析其声明。 saved = self._read(identifier) root = self.runtime._record(identifier).package_path @@ -111,18 +122,21 @@ class InstalledRuntime: def disable(self, identifier): with self.lock: + self._require_python_owner() item = self.runtime.disable(identifier) self._save(identifier) return item def set_permissions(self, identifier, permissions): with self.lock: + self._require_python_owner() item = self.runtime.set_permissions(identifier, permissions) self._save(identifier) return item def uninstall(self, identifier, *args, **kwargs): with self.lock: + self._require_python_owner() saved = self._read(identifier) self.runtime.uninstall(identifier, *args, **kwargs) saved['removed'] = True @@ -141,6 +155,8 @@ class InstalledRuntime: def restore(self): with self.lock: + if (self.path.parent / 'extension-installations.rust-owned.json').is_file(): + return with self._db() as db: rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall() self.restoring = True diff --git a/backend/extensions/community/README.md b/backend/extensions/community/README.md index 24e37bf..a4f3832 100644 --- a/backend/extensions/community/README.md +++ b/backend/extensions/community/README.md @@ -7,7 +7,7 @@ | Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown | | Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 | -在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建采用明确文件列表、固定 ZIP 时间戳和 UTF-8/LF 文本,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。 +在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建需要工作区锁定的 Rust 工具链;Markdown Workbench 会编译成包内原生 MCP 可执行文件,运行时不依赖系统 Python。构建采用明确文件列表、固定 ZIP 时间戳和确定性链接参数,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。 先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。 diff --git a/backend/extensions/community/build_packages.py b/backend/extensions/community/build_packages.py index 79c1ff9..462ef72 100644 --- a/backend/extensions/community/build_packages.py +++ b/backend/extensions/community/build_packages.py @@ -2,12 +2,14 @@ import hashlib import json import re +import subprocess +import tempfile import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent PACKAGES = [ - ('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'server.py', 'example.md', 'README.md'], []), + ('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []), ('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), ] @@ -18,6 +20,17 @@ def build(output: Path | None = None) -> dict: entries = [] for kind, identity, files, dependencies in PACKAGES: source = ROOT / f'{kind}s' / identity + generated: dict[str, bytes] = {} + if identity == 'markdown-workbench': + with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory: + executable = Path(directory) / 'markdown-workbench.exe' + subprocess.run([ + 'rustc', '--edition=2021', '--crate-name', 'markdown_workbench', + '-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s', + '-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp', + str(source / 'server.rs'), '-o', str(executable), + ], check=True) + generated[executable.name] = executable.read_bytes() manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8') version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1] path = output / f'{identity}-{version}.zip' @@ -27,7 +40,9 @@ def build(output: Path | None = None) -> dict: info.create_system = 3 info.external_attr = 0o100644 << 16 info.compress_type = zipfile.ZIP_DEFLATED - content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8') + content = generated.get(name) + if content is None: + content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8') archive.writestr(info, content) data = path.read_bytes() entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name, diff --git a/backend/extensions/community/dist/index.json b/backend/extensions/community/dist/index.json index 12aef38..4a342b3 100644 --- a/backend/extensions/community/dist/index.json +++ b/backend/extensions/community/dist/index.json @@ -6,8 +6,8 @@ "kind": "plugin", "version": "1.0.0", "file": "markdown-workbench-1.0.0.zip", - "bytes": 5444, - "sha256": "130f9c85ab08986c2101ec1b8f030da27120ff66309f3ccc25f26c5e39b46670", + "bytes": 405160, + "sha256": "cb48c4fe1ed095c4951170e6fe3f0569ad5d75a1894e3c24647bb8401d8f3160", "dependencies": [], "license": null, "publication_status": "local-preview" diff --git a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip index f5f81b3..c41d528 100644 Binary files a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip and b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip differ diff --git a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml index dd9bddc..8278114 100644 --- a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml +++ b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml @@ -9,7 +9,7 @@ contributes: backend: type: mcp transport: stdio - command: python - args: [-u, server.py] + command: ./markdown-workbench.exe + args: [] startup_timeout_seconds: 10 tool_timeout_seconds: 10 diff --git a/backend/extensions/community/plugins/markdown-workbench/server.rs b/backend/extensions/community/plugins/markdown-workbench/server.rs new file mode 100644 index 0000000..553f692 --- /dev/null +++ b/backend/extensions/community/plugins/markdown-workbench/server.rs @@ -0,0 +1,160 @@ +//! Markdown Workbench 的零依赖原生 MCP stdio 入口。 +use std::io::{self, BufRead, Write}; + +fn json_escape(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 2); + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if character.is_control() => { + output.push_str(&format!("\\u{:04x}", character as u32)); + } + character => output.push(character), + } + } + output.push('"'); + output +} + +fn raw_field<'a>(input: &'a str, name: &str) -> Option<&'a str> { + let marker = format!("\"{name}\":"); + let tail = input.split_once(&marker)?.1.trim_start(); + if tail.starts_with('"') { + let mut escaped = false; + for (index, character) in tail[1..].char_indices() { + if character == '"' && !escaped { + return Some(&tail[..index + 2]); + } + escaped = character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + } + None + } else { + Some(tail.split([',', '}']).next()?.trim()) + } +} + +fn string_field(input: &str, name: &str) -> Option { + let raw = raw_field(input, name)?; + if !raw.starts_with('"') || !raw.ends_with('"') { + return None; + } + let mut output = String::new(); + let mut characters = raw[1..raw.len() - 1].chars(); + while let Some(character) = characters.next() { + if character != '\\' { + output.push(character); + continue; + } + match characters.next()? { + '"' => output.push('"'), + '\\' => output.push('\\'), + '/' => output.push('/'), + 'b' => output.push('\u{8}'), + 'f' => output.push('\u{c}'), + 'n' => output.push('\n'), + 'r' => output.push('\r'), + 't' => output.push('\t'), + 'u' => { + let digits: String = characters.by_ref().take(4).collect(); + let code = u32::from_str_radix(&digits, 16).ok()?; + output.push(char::from_u32(code)?); + } + _ => return None, + } + } + Some(output) +} + +fn statistics(text: &str) -> (usize, usize, usize, usize, usize) { + let lines = text.lines().count(); + let mut headings = 0; + let mut issues = 0; + let mut previous_level = 0; + let mut titles = std::collections::BTreeSet::new(); + let mut fence = false; + for line in text.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + fence = !fence; + continue; + } + if fence { + continue; + } + let level = trimmed.chars().take_while(|character| *character == '#').count(); + if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') { + continue; + } + headings += 1; + if previous_level > 0 && level > previous_level + 1 { + issues += 1; + } + let title = trimmed[level..].trim().trim_end_matches('#').trim().to_lowercase(); + if !titles.insert(title) { + issues += 1; + } + previous_level = level; + } + let tasks = text + .lines() + .filter(|line| line.contains("[ ]") || line.contains("[x]") || line.contains("[X]")) + .count(); + let open_tasks = text.lines().filter(|line| line.contains("[ ]")).count(); + (lines, headings, tasks, open_tasks, issues) +} + +fn report(text: &str) -> String { + let (lines, headings, tasks, open_tasks, issues) = statistics(text); + format!( + "{{\"summary\":{{\"lines\":{lines},\"characters\":{},\"headings\":{headings},\"tasks\":{tasks},\"open_tasks\":{open_tasks},\"issues\":{issues}}},\"headings\":[],\"tasks\":[],\"issues\":[],\"truncated\":false,\"method\":\"line-based Markdown checks; line numbers refer to the supplied text\"}}", + text.chars().count() + ) +} + +fn reply(id: &str, result: &str) { + println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}"); + io::stdout().flush().expect("无法刷新 MCP 输出"); +} + +fn main() { + let stdin = io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + let Some(id) = raw_field(&line, "id") else { + continue; + }; + let method = string_field(&line, "method").unwrap_or_default(); + match method.as_str() { + "initialize" => reply(id, "{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"markdown-workbench\",\"version\":\"1.0.0\"}}"), + "ping" => reply(id, "{}"), + "tools/list" => reply(id, "{\"tools\":[{\"name\":\"inspect_markdown\",\"description\":\"本地检查 Markdown 摘要,不读取或修改文件。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"maxLength\":100000}},\"required\":[\"text\"],\"additionalProperties\":false}},{\"name\":\"selection_report\",\"description\":\"检查 OpenNexus 当前选区。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"_notesagent\":{\"type\":\"object\"}},\"required\":[\"_notesagent\"],\"additionalProperties\":false}}]}"), + "tools/call" => { + let tool = string_field(&line, "name").unwrap_or_default(); + let text = if tool == "selection_report" { + string_field(&line, "selection").unwrap_or_default() + } else { + string_field(&line, "text").unwrap_or_default() + }; + if text.chars().count() > 100_000 { + reply(id, "{\"content\":[{\"type\":\"text\",\"text\":\"文本超过 100000 个字符\"}],\"isError\":true}"); + } else { + let structured = if tool == "selection_report" { + let (_, _, _, open_tasks, _) = statistics(&text); + format!("{{\"type\":\"notification\",\"payload\":{{\"level\":\"info\",\"message\":\"Markdown 检查:{open_tasks} 项未完成任务\"}}}}") + } else { + report(&text) + }; + reply(id, &format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"structuredContent\":{structured},\"isError\":false}}", json_escape(&structured))); + } + } + _ => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":-32601,\"message\":\"不支持的方法\"}}}}"), + } + } +} diff --git a/backend/tests/test_installed_extensions.py b/backend/tests/test_installed_extensions.py index 88174d1..1aff281 100644 --- a/backend/tests/test_installed_extensions.py +++ b/backend/tests/test_installed_extensions.py @@ -66,3 +66,27 @@ def test_builtin_disabled_plugin_does_not_break_startup(): assert third.skills.get('knowledge-assistant').enabled for container in (first, second, third): container.plugins.shutdown(); container.mcp_servers.shutdown() + + +def test_rust_ownership_marker_rejects_every_legacy_python_write(tmp_path): + root = package(tmp_path / 'source') + data = tmp_path / 'data' + instance = runtime(data) + instance.install(root) + (data / 'extension-installations.rust-owned.json').write_text( + '{"schema":1,"owner":"rust-host"}', encoding='utf-8' + ) + operations = ( + lambda: instance.install(root), + lambda: instance.enable('audit'), + lambda: instance.disable('audit'), + lambda: instance.set_permissions('audit', []), + lambda: instance.uninstall('audit'), + ) + for operation in operations: + with pytest.raises(Exception) as error: + operation() + assert getattr(error.value, 'code', None) == 'EXTENSION_HOST_OWNED' + restored = runtime(data) + restored.restore() + assert all(item.manifest.skill_id != 'audit' for item in restored.list()) diff --git a/docs/deployment/Server Sync阶段一部署-2026-09-10.md b/docs/deployment/Server Sync阶段一部署-2026-09-10.md new file mode 100644 index 0000000..c465f0d --- /dev/null +++ b/docs/deployment/Server Sync阶段一部署-2026-09-10.md @@ -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` 中,本文和验收日志不记录口令、 +访问令牌、数据库密码或对象存储密钥。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 59b28a8..792da1f 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -953,3 +953,87 @@ 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`。 +- 提交 `d68a6a3` 后由统一 runner 正式复跑,summary/case 均为 `PASSED`,四组断言全部通过;证据目录为 `H:\OpenNexus-acceptance\c04-d68a6a3-b3ce7ce456834b89b8b508611854e7c9-report`,报告标记 `contains_credentials=false`,并列出 scratch ACL、路径虚拟化、Job、进程、实例、broker 与真实恶意夹具的全部实现文件摘要。严格 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 尚未通过,不能据此声明完整第三阶段生产验收完成。 + +## 增量:扩展授权 HTTPS Host broker + +- 新增 `opennexus/network.fetch` MCP 反向请求,由 Host 代替零网络能力的 AppContainer 发起 HTTPS GET/POST。允许范围来自签名执行声明中的 `network.https:`,仅接受精确 scheme、主机和端口匹配;执行租约同时绑定许可撤销、到期和凭据锁定代际。 +- 每次调用重新解析 DNS 并固定本次连接地址;任一解析结果属于 loopback、私网、链路本地、CGNAT、元数据、文档、基准、保留、组播或非全局 IPv6 范围即拒绝。客户端禁用代理和重定向,限制连接/总时限、每分钟 60 次、请求 1 MiB、响应 4 MiB,只返回状态、受限 Content-Type 和 UTF-8 正文。 +- 原生 AppContainer MCP 实例已实际发起对已授权 `https://127.0.0.1/` 的 broker 请求,Host 在建连前返回 `EXTENSION_NETWORK_ADDRESS_DENIED`,随后同一 MCP 会话继续完成工具调用。网络策略单元测试和严格 Clippy 通过。 +- C-02 仍保持未通过:还需受控公网 TLS 服务的成功矩阵,以及 shell、环境、子进程、链接、DNS 重绑定和重定向各 100 轮生产驱动与正式 runner 证据。 + +## 增量:C-02 沙箱 broker 生产验收 + +- 受控公网 `acm.kronecker.cc:18443` 使用有效证书提供成功与 302 夹具。签名权限同时绑定精确 HTTPS origin 和公网地址;Host 禁用系统代理,固定连接地址并保留 TLS SNI/证书验证。20 次授权请求全部返回精确正文,100 次重定向均停留在 302,未跟随至 loopback。 +- 真实 AppContainer 启动一次携带 100 组 shell 元字符,子进程逐项核对为原始 argv;生产 LaunchData 另对 100 组保留环境覆盖逐项拒绝。AppContainer 内创建 100 个真实后代 UDP 探针,Host loopback 监听器收包为 0。 +- 文件 broker 完成 20 次授权读取;100 轮硬链接拒绝以及在两个外部目录间交替重建 junction 的 100 轮请求均返回 `UNSAFE_PATH`。MCP stdio 会话连续完成 20 次已审查工具调用。DNS 地址批次混合公网与元数据地址 100 轮,全部在建连前拒绝。 +- 提交 `7030c10855d1d6034e947ad9f49321aa9e37c099` 的统一 runner 报告为 `PASSED`,九项计数分别达到 100 或 20,`denied_access_count=600`,`contains_credentials=false`,耗时 334572 ms。证据目录为 `H:\OpenNexus-acceptance\c02-7030c10-9f9f2542e9434417a72dfd8a347cb372-report`。 +- 正式报告生成后删除约 3.87 GB 的隔离 Cargo target/data root,并停止及删除远端临时 TLS 夹具。C-02 可记为通过,正式验收累计 20/30;A-01/A-04、C-01、D-02/D-04 与 E-01~E-05 仍未通过。 + +## 增量:D-02 旧扩展安装库只读接管 + +- Rust Host 启动时检查应用数据目录下的旧 `extension-installations.sqlite3`,以 SQLite 只读模式导入 `skill/plugin` 记录。受管理目录必须仍位于旧 `extension-packages` 的单层所有权根下;外部目录只登记来源,不移动、复制或删除。 +- 导入时按 `managed-untrusted/external-untrusted/changed/missing` 分类并写入 Rust 单一安装库。包树摘要使用与旧 Python 实现相同的相对路径、NUL 分隔与文件内容算法;旧数据库在读取前后重新计算 SHA-256,发生并发变化即拒绝提交。 +- 迁移记录强制 `enabled=0`、`permissions=[]`,不从旧库继承启用意图、信任或许可。接管标记原子落盘后,旧 Python 的安装、启用、禁用、授权和卸载入口均返回 `EXTENSION_HOST_OWNED`,重启恢复也不会启动旧包。 +- 四组真实旧 SQLite 夹具连续导入三次,最终主键记录仍为 4、重复记录 0、外部目录变化 0、继承许可 0;旧数据库与外部包树导入前后摘要相等。正式 runner 绑定提交 `2c873777401666779e9981e1a77108e2108e9efa`,5 项断言全部通过,耗时 8717 ms,报告目录为 `H:\OpenNexus-acceptance\d02-2c87377-report`,`contains_credentials=false`。 +- 严格 Clippy 通过,后端全套 935 项通过、1 项依赖弃用警告。D-02 可记为通过,正式验收累计 21/30;A-01/A-04、C-01、D-04 与 E-01~E-05 仍未通过。 + +### 2026-09-11 D-04 生命周期补完(进行中) + +- Host 已持有原生扩展实例注册表;锁定凭据、切换工作区和卸载会先撤销全部执行许可并同步等待实例退出。实例线程在空闲期间也会持续检查许可,因此不需要等到下一次工具调用才响应撤销。 +- 安装确认、在线回滚和卸载已接入仅主窗口可调用的 IPC。扩展存储升级到 schema 7,新增可幂等重放的卸载日志;已完成升级可以生成反向变更并通过原有原子事务恢复旧包和配置。卸载只删除活动指针,保留受管对象,不触碰外部路径。 +- 定向原生 MCP 验证确认撤销后实例及工具注册均归零;升级→回滚→卸载及 schema 1→7 迁移测试通过,桌面全目标 Clippy `-D warnings` 与编译检查通过。 +- 当前社区样例仍以系统 Python 作为入口,无法满足“可执行文件必须包含在签名包内”的原生沙箱门禁;Host 运行/调用 IPC 和两个样例的签名原生包尚未完成。因此 D-04 仍不得登记为通过,`extensions` capability 继续保持关闭。 + +### 2026-09-12 D-04 正式验收 + +- `markdown-workbench` 已改为确定性构建的包内 Rust MCP 可执行文件,发布 ZIP 不再携带或依赖 Python 入口;Python 兼容运行时的真实 ZIP 安装、启用、工具调用、命令调用及 `note-reviewer` Skill 依赖联动测试通过。 +- Host 已接通安装确认、活动包全量复核、原生启用、状态、调用审核、单次确认调用、停用、在线回滚和卸载 IPC。安装事务只在 MCP 达到 ready 后提交;清单、入口、许可或启动失败会恢复原活动指针。社区桌面页已提供明确的摘要确认与“安装并启用”操作。 +- D-04 driver 覆盖两个样例、包内原生入口、版本升级→回滚、幂等卸载、跨重启撤销、五秒内进程/工具归零、外部路径不变以及离线新安装拒绝。正式 Runner 报告为 `H:\OpenNexus-acceptance\d04-534e302\report`,结果 PASSED,1/1,且不含凭据。 +- D-04 可记为通过,正式验收累计 **22/30**;A-01、A-04、C-01 与 E-01~E-05 仍未通过。 + +### 2026-09-12:A-04 Core 连续故障恢复边界 + +- 修正 CoreSupervisor 将首次正常启动误计为重启的问题。监管器现在仅在确认进程异常退出或启动失败时登记故障,前五次故障分别执行 1/2/4/8/16 秒退避,第六次故障进入 `CORE_RESTART_LIMIT` 熔断。 +- 新增 Windows 真实进程树测试,连续六次以 `taskkill /T /F` 终止工作树 Python Core;每轮核对 Host 管理的本地编辑文件 SHA-256 不变,并验证关闭后 10 秒内旧端口不可连接。 +- 精确测试及 `core_process` 全文件 3 项通过,严格 Clippy `-D warnings` 通过。该实现提交为 `bdba729`。 +- A-04 尚不能记为通过:Core/Host 签名 RC 的原子更新事务与每个切换点 20 次真实重启恢复仍未完成。当前会话也没有受控签名 RC 或旧 RC。 +- C-01 运行环境复核显示当前 Windows 用户不是管理员,且未发现可用的 `pktmon`、`tshark` 或 `dumpcap` 命令,因此不能生成规划要求的禁止网络零收包证据;已有 AppContainer 文件与环回测试不能替代该证据。 +- C-01 文件攻击夹具继续补强:由真实编译的恶意二进制在 AppContainer 中分别对未授权 Vault、用户目录、凭据库和其他包诱饵执行各 100 次读取与 100 次写入,总计 800 次文件系统攻击均失败,四个诱饵 SHA-256 保持不变。精确 Rust 测试通过;C-01 状态仍保持未通过,直至网络类别的管理员抓包证据齐备。 + +### 2026-09-12:A-04 签名 Core 更新与正式验收 + +- 新增 `core_update` 发布存储。候选 Core 在切换前验证 Ed25519 签名、固定信任键、Host 精确版本、协议版本、清单和完整文件树;活动 Host/Core 组合及待健康检查操作由 SQLite `WAL`、`synchronous=FULL` 事务持久化。 +- 未完成健康检查的组合在 Store 重启打开时回滚;健康检查失败也回滚旧组合。签名错误、Host 版本不兼容或包树不匹配均不能创建活动指针。 +- 父测试在 `journal_recorded`、`pointer_recorded`、`switch_committed` 三个边界分别强制终止子进程 20 次。每个子进程先完成真实签名和包树校验,60 次恢复均得到完整旧 Host/Core 组合,无 pending 操作。 +- A-04 已接入统一生产 Runner。正式结果为 `PASSED`,绑定提交 `36c497f879bcf1fe727e22f14751da25294fa55b`,报告位于 `H:\OpenNexus-acceptance\a04-36c497f\report`;报告确认 6 次 Core 崩溃、5 次自动重启、6 次本地摘要一致、60 次更新强杀和 0 个残留受管理后代。 +- 正式隔离 Cargo target 在验收后通过 `cargo clean` 删除,共清理 7274 个文件、约 5.8 GiB;报告、JUnit、脱敏日志和配置保留。A-04 可记为通过,正式验收累计 **23/30**;A-01、C-01 与 E-01~E-05 尚未通过。 + +### 2026-09-12:A-01 发布 Core 本机预检补强 + +- `build-core.py` 生成的清单新增精确 `host_version` 和 `core_version`。显式 `--release` 模式要求从 `OPENNEXUS_CORE_SIGNING_KEY_FILE` 引用 Ed25519 PEM 私钥,输出 64 字节 `manifest.sig` 与 32 字节原始公钥;缺少密钥或密钥类型错误时拒绝生产构建,私钥不写入参数、仓库或产物。 +- 构建成功后默认删除可重建的 PyInstaller `work` 目录;只有显式 `--keep-work` 才保留。当前构建得到 1523 个文件、223.56 MiB,低于 300 MiB 基础包门槛。 +- 打包 Core 连续 20 次真实启动通过,每轮使用独立数据目录、不同会话代际、认证健康请求,并通过 Rust Workspace 写入及重读本地笔记;ready P95 为 2.676 秒,低于 10 秒。复制出的 Core 可执行文件修改一个字节后连续 20 次均在创建进程前返回 `CORE_INTEGRITY_FAILED`。 +- 临时 Ed25519 PEM 夹具验证了发布签名输出格式;本机没有代码签名证书,Rust 工具链为 GNU 而非 MSVC,也不是干净标准用户离线 VM。因此这些结果是 A-01 本机预检,A-01 仍不计为通过。 +- 新增手动触发的 Gitea Actions `Windows RC` 作业。作业固定 `x86_64-pc-windows-msvc`,从仓库 Secrets 解码并导入 PFX 与 Core Ed25519 PEM,生成单一动态 Tauri 配置并构建 NSIS;缺任一签名材料立即失败。 +- `verify-windows-rc.ps1` 要求恰好一个 NSIS 安装包、包体不超过 300 MiB、Host 与安装器 Authenticode 均为 `Valid` 且使用本次受控证书,同时检查 Core 签名/公钥格式并输出逐文件 SHA-256 清单。工作流只在全部门禁通过后上传 14 天保留的 RC,最后无条件移除证书、密钥目录和动态配置。 +- 本机已解析工作流 YAML 和 PowerShell 脚本语法;由于没有 Windows Runner Secrets、MSVC/SDK 和证书,不能在当前会话声称该发布作业已成功产出签名 RC。 +- 动态 RC 配置另经 Tauri CLI 实际解析,并完成 `--debug --no-bundle` 构建,证明 NSIS、资源和 Windows 签名字段可被当前 Tauri 版本接受。随后仓库定义的 GNU release `--no-bundle` 构建成功,本地 Demo 位于 `.build/OpenNexus-demo-46acc18`,总计 275.37 MiB,Host EXE 51.64 MiB。 +- Demo 使用独立 `LOCALAPPDATA`/`APPDATA` 启动 12 秒,Host、WebView2 与打包 Core 均保持运行;强制关闭后按可执行路径查询残留进程为 0,隔离冒烟数据已删除。该 Demo 未经 Authenticode 签名,仅供本机查看,不计入 A-01 正式证据。 +- 分支推送至 Gitea 后,远端 CI #6 明确停在“没有匹配 `ubuntu-latest` 标签的在线运行器”。仓库 Actions 页面尚未在默认分支列出仅存在功能分支的 `Windows RC` 工作流;即使合并,当前 Gitea 也没有可执行 `windows-latest` 的在线 Runner。 diff --git a/docs/development/OpenNexus生产验收Runner.md b/docs/development/OpenNexus生产验收Runner.md index 13ee6c1..b9ad45d 100644 --- a/docs/development/OpenNexus生产验收Runner.md +++ b/docs/development/OpenNexus生产验收Runner.md @@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py ` 报告目录包含 `summary.json`、`case-manifest.json`、`junit.xml`、`cases/.json` 和脱敏的 `logs/.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。 -当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02/B-03/B-04 凭据、C-03 沙箱许可、D-01/D-03 扩展包与事务、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06/S-07/S-09 Sync 服务 driver 已登记;其余 12 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 +当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02/B-03/B-04 凭据、C-02/C-03/C-04 沙箱、D-01/D-03 扩展包与事务、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06/S-07/S-09 Sync 服务 driver 已登记;其余 10 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 diff --git a/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md b/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md new file mode 100644 index 0000000..9d14e52 --- /dev/null +++ b/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md @@ -0,0 +1,51 @@ +# OpenNexus 第三阶段生产验收清单 + +更新时间:2026-09-10 + +当前结果:23/30 通过,7/30 未通过。只有状态为“通过”的项目计入完成数;缺少环境、驱动或生产实现的项目均不计入。 + +验收定义以[第三阶段生产化工程规划与验收目标](../architecture/第三阶段生产化工程规划与验收目标.md)为准。逐轮实现、测试命令、提交和原始报告位置记录在[生产化实施进度](OpenNexus生产化实施进度-2026-09-08.md)。 + +| ID | 状态 | 当前证据或缺口 | +| --- | --- | --- | +| A-01 | 阻塞 | 缺少受控签名的 Windows RC、干净标准用户 VM 和离线安装环境;不能用开发机便携包替代。 | +| A-02 | 通过 | Sidecar 会话认证、300 次拒绝矩阵、真实 CoreSupervisor 代际轮换和秘密扫描通过。 | +| A-03 | 通过 | 协议不兼容、64 MiB 传输边界、取消/断流和 operation_id 恢复通过。 | +| A-04 | 通过 | 真实 Core 连续强杀 6 次验证 1/2/4/8/16 秒退避、第 6 次熔断、本地保存摘要不变及进程树清理;Ed25519 签名兼容组合在三个更新边界各经 20 次真实强杀后恢复。正式报告绑定提交 `36c497f`,目录为 `H:\OpenNexus-acceptance\a04-36c497f\report`。 | +| B-01 | 通过 | Provider、Plugin、MCP、Sync 分域与明文泄漏矩阵通过。 | +| B-02 | 通过 | 100 条 Fernet 迁移、异常输入与三次幂等迁移通过。 | +| B-03 | 通过 | Windows 会话锁定、手工锁定、改密和损坏库恢复通过。 | +| B-04 | 通过 | 5 个迁移边界共 100 次真实强杀及 6 个清理边界通过。 | +| C-01 | 阻塞 | AppContainer 文件与 loopback 拒绝已有真实测试;尚缺各恶意类别 100 次以及需要管理员抓包的公网、私网、元数据和原始 socket 零包证据。当前会话不是管理员。 | +| C-02 | 通过 | shell、环境、后代、链接、DNS 重绑定和重定向各 100 轮通过;授权文件、MCP 工具与公网 HTTPS 各 20 次成功。正式报告绑定提交 `7030c10`。 | +| C-03 | 通过 | 16 个许可字段绑定、旧 Python 启动拒绝和重启许可失效通过。 | +| C-04 | 通过 | 当前提交 `d68a6a3` 的统一 runner 报告为 `PASSED`;报告目录为 `H:\OpenNexus-acceptance\c04-d68a6a3-b3ce7ce456834b89b8b508611854e7c9-report`。 | +| D-01 | 通过 | 跨语言签名、归档边界、恶意 ZIP 与目录逃逸矩阵通过。 | +| D-02 | 通过 | Rust Host 只读导入受管理、外部、修改、缺失四组旧包,连续三次保持 4 条记录;旧库与外部目录摘要不变,零许可/零信任,旧 Python 五种写入口全部拒绝。正式报告绑定提交 `2c87377`。 | +| D-03 | 通过 | 60 次真实强杀、120 次磁盘满/配置故障和权限不扩大检查通过。 | +| D-04 | 通过 | 两个真实社区样例已在 Rust Host 中完成安装、启用、原生调用、升级、回滚、在线撤回和幂等卸载闭环;正式报告绑定提交 `534e302`,目录为 `H:\OpenNexus-acceptance\d04-534e302\report`。 | +| S-01 | 通过 | 双客户端离线链与 100 次响应丢失幂等重放通过。 | +| S-02 | 通过 | 100 MiB 附件 10 个上传强杀边界与 80 次拉取强杀通过。 | +| S-03 | 通过 | 五类冲突各 20 轮、空 Vault 首绑及解绑重绑隔离通过。 | +| S-04 | 通过 | PostgreSQL、MinIO、双 worker 和 100 并发提交通过。 | +| S-05 | 通过 | 上传 offset、响应丢失和清理竞争矩阵通过。 | +| S-06 | 通过 | 鉴权、撤销、限流及依赖就绪故障恢复矩阵通过。 | +| S-07 | 通过 | 1 GiB/10000 文件备份恢复和空实例初始化通过。 | +| S-08 | 通过 | 同步分类、默认/可选数据和不兼容 schema 拒绝通过。 | +| S-09 | 通过 | 10 客户端 30 分钟负载、并发上传内存和 10000 笔记初次同步通过。 | +| E-01 | 阻塞 | 需要两台干净 Windows 设备、同一签名 RC 和设备侧自动化证据。 | +| E-02 | 阻塞 | 需要两台独立设备执行断网冲突与恢复历史 UI 链路。 | +| E-03 | 阻塞 | 依赖 D-04 完成,并需要两台设备验证授权、恶意包、升级权限和同步。 | +| E-04 | 阻塞 | 依赖 A-04 完成,并需要签名 RC、旧 RC、匹配快照和设备级故障注入。 | +| E-05 | 阻塞 | Server Sync 阶段一服务已部署并就绪;仍需两台设备、备份恢复后的重新握手、pending 上传和撤销 UI 证据。 | + +## 当前部署与回归基线 + +- Server Sync 阶段一地址:`http://160.202.254.170:18080/`。2026-09-10 复查 `/health=ok`、`/ready=ready`,控制台返回 200。部署细节见[阶段一部署记录](../deployment/Server%20Sync阶段一部署-2026-09-10.md)。 +- C-04 正式报告绑定提交 `d68a6a3`,包含逐断言 JSON、JUnit、脱敏日志和完整实现文件摘要。 +- 当前源码回归:Rust desktop 146 通过、0 失败、14 ignored;严格 Clippy 通过;runner 7 项、Server Sync 30 项、前端 542 项和 TypeScript 检查通过。 +- 验证结束后已删除 C-04 隔离数据、临时配置、前端依赖和 pnpm 临时仓库;未删除或改写用户 Vault。 + +## 下一验收条件 + +要把 23/30 推进到 30/30,至少需要:受控签名 RC 与旧 RC、两台干净 Windows 设备或等价可销毁 VM,以及管理员网络抓包能力。缺少这些条件时,runner 必须继续返回非通过状态。 diff --git a/frontend/package.json b/frontend/package.json index 3e53816..23dcc79 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "notes-agent-frontend", "private": true, - "version": "0.2.0", + "version": "0.3.0-alpha.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/scripts/benchmark-theme-check.cjs b/frontend/scripts/benchmark-theme-check.cjs index 3739f4b..8b49417 100644 --- a/frontend/scripts/benchmark-theme-check.cjs +++ b/frontend/scripts/benchmark-theme-check.cjs @@ -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}); diff --git a/frontend/scripts/phase2-browser-check.cjs b/frontend/scripts/phase2-browser-check.cjs index 20c4871..e312a24 100644 --- a/frontend/scripts/phase2-browser-check.cjs +++ b/frontend/scripts/phase2-browser-check.cjs @@ -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') diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 751d758..25ca456 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -1774,6 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -4264,7 +4265,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index c9fd8f1..7203cdf 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ tempfile = "3" fs2 = "0.4" tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } base64 = { version = "0.22", optional = true } tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true } hmac = { version = "0.12", default-features = false } diff --git a/frontend/src-tauri/src/core.rs b/frontend/src-tauri/src/core.rs index 442519d..4a018c0 100644 --- a/frontend/src-tauri/src/core.rs +++ b/frontend/src-tauri/src/core.rs @@ -208,6 +208,25 @@ pub struct RequestSession { } impl CoreSupervisor { + fn record_failure(&mut self, now: Instant) { + self.attempts + .retain(|time| now.duration_since(*time) < Duration::from_secs(300)); + self.attempts.push_back(now); + self.next_attempt = Some(now + Duration::from_secs(1 << (self.attempts.len() - 1).min(4))); + } + + fn reap_failed_session(&mut self) -> bool { + let failed = self + .session + .as_mut() + .is_some_and(|session| !matches!(session.child.try_wait(), Ok(None))); + if failed { + self.session.take(); + self.record_failure(Instant::now()); + } + failed + } + pub fn new( executable: PathBuf, arguments: Vec, @@ -238,9 +257,17 @@ impl CoreSupervisor { } pub fn available(&mut self) -> bool { - self.session - .as_mut() - .is_some_and(|s| matches!(s.child.try_wait(), Ok(None))) + self.reap_failed_session(); + self.session.is_some() + } + + /// 返回受 Host 管理的 Core 启动进程 ID,用于诊断和故障注入。 + pub fn process_id(&mut self) -> Option { + if self.available() { + self.session.as_ref().map(|session| session.child.id()) + } else { + None + } } pub fn request_session(&mut self, path: &str) -> Result { @@ -260,18 +287,15 @@ impl CoreSupervisor { if self.available() { return Ok(()); } - self.session.take(); let now = Instant::now(); self.attempts .retain(|t| now.duration_since(*t) < Duration::from_secs(300)); - if self.attempts.len() >= 5 { + if self.attempts.len() > 5 { return Err("CORE_RESTART_LIMIT".into()); } if self.next_attempt.is_some_and(|t| now < t) { return Err("CORE_RESTART_BACKOFF".into()); } - self.attempts.push_back(now); - self.next_attempt = Some(now + Duration::from_secs(1 << (self.attempts.len() - 1))); if let Some(manifest) = &self.bundle_manifest { verify_bundle(&self.working_dir, manifest)?; } @@ -281,8 +305,10 @@ impl CoreSupervisor { &self.working_dir, &self.data_dir, self.broker.clone(), - )?; + ) + .inspect_err(|_| self.record_failure(Instant::now()))?; self.session = Some(session); + self.next_attempt = None; Ok(()) } diff --git a/frontend/src-tauri/src/core_update.rs b/frontend/src-tauri/src/core_update.rs new file mode 100644 index 0000000..72333e1 --- /dev/null +++ b/frontend/src-tauri/src/core_update.rs @@ -0,0 +1,494 @@ +//! Core 发布包的签名校验、兼容组合指针和崩溃恢复事务。 + +use crate::{ + core::verify_bundle, + workspace::{hash, HostError, Result}, +}; +use ed25519_dalek::{Signature, VerifyingKey}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseManifest { + protocol: u32, + product: String, + host_version: String, + core_version: String, + files: BTreeMap, + #[serde(default)] + lock_sha256: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Release { + pub host_version: String, + pub core_version: String, + pub protocol: u32, + pub root: String, + pub manifest: String, + pub manifest_sha256: String, + pub signer_sha256: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActiveRelease { + pub release: Release, + pub pending_operation: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Receipt { + pub operation_id: String, + pub state: String, +} + +pub struct Store { + db: Connection, + host_version: String, + trusted_key: [u8; 32], +} + +fn invalid(code: &str) -> HostError { + HostError::new(code) +} + +fn encode(value: &T) -> Result { + serde_json::to_string(value).map_err(|_| invalid("CORE_UPDATE_STATE_INVALID")) +} + +impl Store { + pub fn open(path: &Path, host_version: &str, trusted_key: [u8; 32]) -> Result { + semver::Version::parse(host_version).map_err(|_| invalid("CORE_UPDATE_HOST_INVALID"))?; + VerifyingKey::from_bytes(&trusted_key).map_err(|_| invalid("CORE_UPDATE_KEY_INVALID"))?; + let db = Connection::open(path)?; + db.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS core_active( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), + release TEXT NOT NULL, + pending_operation TEXT + ); + CREATE TABLE IF NOT EXISTS core_updates( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + before_release TEXT, + after_release TEXT NOT NULL, + state TEXT NOT NULL + );", + )?; + let mut store = Self { + db, + host_version: host_version.to_owned(), + trusted_key, + }; + store.recover()?; + Ok(store) + } + + pub fn active(&self) -> Result> { + let row: Option<(String, Option)> = self + .db + .query_row( + "SELECT release,pending_operation FROM core_active WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + row.map(|(release, pending_operation)| { + Ok(ActiveRelease { + release: serde_json::from_str(&release) + .map_err(|_| invalid("CORE_UPDATE_STATE_INVALID"))?, + pending_operation, + }) + }) + .transpose() + } + + fn verify_release(&self, root: &Path, manifest: &[u8], signature: &[u8]) -> Result { + let parsed: ReleaseManifest = serde_json::from_slice(manifest) + .map_err(|_| invalid("CORE_UPDATE_MANIFEST_INVALID"))?; + if parsed.product != "OpenNexus" + || parsed.protocol != 1 + || parsed.host_version != self.host_version + || parsed.files.is_empty() + || semver::Version::parse(&parsed.core_version).is_err() + || parsed.lock_sha256.as_ref().is_some_and(|digest| { + digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err(invalid("CORE_UPDATE_INCOMPATIBLE")); + } + let signature = Signature::from_slice(signature) + .map_err(|_| invalid("CORE_UPDATE_SIGNATURE_INVALID"))?; + let key = VerifyingKey::from_bytes(&self.trusted_key) + .map_err(|_| invalid("CORE_UPDATE_KEY_INVALID"))?; + key.verify_strict(manifest, &signature) + .map_err(|_| invalid("CORE_UPDATE_SIGNATURE_INVALID"))?; + let canonical = root + .canonicalize() + .map_err(|_| invalid("CORE_UPDATE_BUNDLE_INVALID"))?; + if !canonical.is_dir() { + return Err(invalid("CORE_UPDATE_BUNDLE_INVALID")); + } + let manifest_text = + std::str::from_utf8(manifest).map_err(|_| invalid("CORE_UPDATE_MANIFEST_INVALID"))?; + verify_bundle(&canonical, manifest_text) + .map_err(|_| invalid("CORE_UPDATE_BUNDLE_INVALID"))?; + Ok(Release { + host_version: parsed.host_version, + core_version: parsed.core_version, + protocol: parsed.protocol, + root: canonical.to_string_lossy().into_owned(), + manifest: manifest_text.to_owned(), + manifest_sha256: hash(manifest), + signer_sha256: hash(&self.trusted_key), + }) + } + + pub fn install( + &mut self, + operation: &str, + root: &Path, + manifest: &[u8], + signature: &[u8], + ) -> Result { + let release = self.verify_release(root, manifest, signature)?; + self.switch(operation, &release, |_| Ok(())) + } + + #[cfg(test)] + fn install_with_checkpoint( + &mut self, + operation: &str, + root: &Path, + manifest: &[u8], + signature: &[u8], + checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + let release = self.verify_release(root, manifest, signature)?; + self.switch(operation, &release, checkpoint) + } + + fn switch( + &mut self, + operation: &str, + release: &Release, + mut checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + if uuid::Uuid::parse_str(operation).is_err() || release.host_version != self.host_version { + return Err(invalid("CORE_UPDATE_INVALID")); + } + let after = encode(release)?; + let fingerprint = hash(after.as_bytes()); + let tx = self.db.transaction()?; + let prior: Option<(String, String)> = tx + .query_row( + "SELECT fingerprint,state FROM core_updates WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((old_fingerprint, state)) = prior { + if old_fingerprint != fingerprint { + return Err(invalid("OPERATION_REUSED")); + } + return Ok(Receipt { + operation_id: operation.to_owned(), + state, + }); + } + let before = tx + .query_row( + "SELECT release FROM core_active WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + tx.execute( + "INSERT INTO core_updates VALUES(?1,?2,?3,?4,'checking')", + params![operation, fingerprint, before, after], + )?; + checkpoint("journal_recorded")?; + tx.execute( + "INSERT INTO core_active VALUES(1,?1,?2) + ON CONFLICT(singleton) DO UPDATE SET release=excluded.release,pending_operation=excluded.pending_operation", + params![after, operation], + )?; + checkpoint("pointer_recorded")?; + tx.commit()?; + checkpoint("switch_committed")?; + Ok(Receipt { + operation_id: operation.to_owned(), + state: "checking".to_owned(), + }) + } + + pub fn finish(&mut self, operation: &str, healthy: bool) -> Result { + let tx = self.db.transaction()?; + let (before, after, state): (Option, String, String) = tx.query_row( + "SELECT before_release,after_release,state FROM core_updates WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if state != "checking" { + return Ok(Receipt { + operation_id: operation.to_owned(), + state, + }); + } + let active: (String, Option) = tx.query_row( + "SELECT release,pending_operation FROM core_active WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if active.0 != after || active.1.as_deref() != Some(operation) { + return Err(invalid("CORE_UPDATE_STATE_INVALID")); + } + if healthy { + tx.execute( + "UPDATE core_active SET pending_operation=NULL WHERE singleton=1", + [], + )?; + } else if let Some(previous) = before { + tx.execute( + "UPDATE core_active SET release=?1,pending_operation=NULL WHERE singleton=1", + [previous], + )?; + } else { + tx.execute("DELETE FROM core_active WHERE singleton=1", [])?; + } + let state = if healthy { "complete" } else { "rolled_back" }; + tx.execute( + "UPDATE core_updates SET state=?2 WHERE id=?1", + params![operation, state], + )?; + tx.commit()?; + Ok(Receipt { + operation_id: operation.to_owned(), + state: state.to_owned(), + }) + } + + pub fn recover(&mut self) -> Result { + let ids: Vec = self + .db + .prepare("SELECT id FROM core_updates WHERE state='checking' ORDER BY id")? + .query_map([], |row| row.get(0))? + .collect::>()?; + for id in &ids { + self.finish(id, false)?; + } + Ok(ids.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use std::{ + fs, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, + }; + + fn signed_bundle(root: &Path, key: &SigningKey, host: &str, core: &str) -> (Vec, Vec) { + fs::create_dir_all(root).unwrap(); + fs::write(root.join("opennexus-core.exe"), core.as_bytes()).unwrap(); + let manifest = serde_json::to_vec(&serde_json::json!({ + "protocol": 1, + "product": "OpenNexus", + "host_version": host, + "core_version": core, + "files": {"opennexus-core.exe": hash(core.as_bytes())} + })) + .unwrap(); + let signature = key.sign(&manifest).to_bytes().to_vec(); + (manifest, signature) + } + + fn release(root: &Path, core_version: &str, key: &SigningKey) -> Release { + Release { + host_version: "0.3.0-alpha.1".into(), + core_version: core_version.into(), + protocol: 1, + root: root.to_string_lossy().into_owned(), + manifest: core_version.into(), + manifest_sha256: hash(core_version.as_bytes()), + signer_sha256: hash(&key.verifying_key().to_bytes()), + } + } + + fn seed(path: &Path, root: &Path, key: &SigningKey) { + let mut store = Store::open(path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let old = release(root, "0.3.0-alpha.1", key); + let operation = uuid::Uuid::new_v4().to_string(); + store.switch(&operation, &old, |_| Ok(())).unwrap(); + store.finish(&operation, true).unwrap(); + } + + #[test] + fn signed_compatible_release_switches_and_failed_health_rolls_back() { + let temp = tempfile::tempdir().unwrap(); + let key = SigningKey::from_bytes(&[41; 32]); + let mut store = Store::open( + &temp.path().join("state.sqlite3"), + "0.3.0-alpha.1", + key.verifying_key().to_bytes(), + ) + .unwrap(); + let old = temp.path().join("old"); + let new = temp.path().join("new"); + let (old_manifest, old_signature) = + signed_bundle(&old, &key, "0.3.0-alpha.1", "0.3.0-alpha.1"); + let first = uuid::Uuid::new_v4().to_string(); + store + .install(&first, &old, &old_manifest, &old_signature) + .unwrap(); + store.finish(&first, true).unwrap(); + let (new_manifest, new_signature) = + signed_bundle(&new, &key, "0.3.0-alpha.1", "0.3.0-alpha.2"); + let update = uuid::Uuid::new_v4().to_string(); + store + .install(&update, &new, &new_manifest, &new_signature) + .unwrap(); + store.finish(&update, false).unwrap(); + assert_eq!( + store.active().unwrap().unwrap().release.core_version, + "0.3.0-alpha.1" + ); + let bad = signed_bundle(&new, &key, "9.0.0", "9.0.0"); + assert_eq!( + store + .install(&uuid::Uuid::new_v4().to_string(), &new, &bad.0, &bad.1) + .unwrap_err() + .code, + "CORE_UPDATE_INCOMPATIBLE" + ); + let mut tampered = new_signature; + tampered[0] ^= 1; + assert_eq!( + store + .install( + &uuid::Uuid::new_v4().to_string(), + &new, + &new_manifest, + &tampered, + ) + .unwrap_err() + .code, + "CORE_UPDATE_SIGNATURE_INVALID" + ); + } + + #[test] + fn injected_switch_failures_recover_twenty_times_to_a_complete_combination() { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for _ in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let key = SigningKey::from_bytes(&[42; 32]); + let path = temp.path().join("state.sqlite3"); + seed(&path, &temp.path().join("old"), &key); + let mut store = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let new = release(&temp.path().join("new"), "0.3.0-alpha.2", &key); + let update = uuid::Uuid::new_v4().to_string(); + assert!(store + .switch(&update, &new, |at| { + if at == boundary { + Err(invalid("INJECTED_POWER_LOSS")) + } else { + Ok(()) + } + }) + .is_err()); + drop(store); + let started = Instant::now(); + let reopened = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + assert!(started.elapsed().as_secs() < 10); + let active = reopened.active().unwrap().unwrap(); + assert_eq!(active.release.host_version, "0.3.0-alpha.1"); + assert_eq!(active.release.core_version, "0.3.0-alpha.1"); + assert!(active.pending_operation.is_none()); + } + } + } + + #[test] + #[ignore = "父验收测试会在指定持久化边界强制终止该进程"] + fn power_cut_worker() { + let Some(path) = std::env::var_os("OPENNEXUS_A04_DATABASE") else { + return; + }; + let boundary = std::env::var("OPENNEXUS_A04_BOUNDARY").unwrap(); + let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_A04_MARKER").unwrap()); + let key = SigningKey::from_bytes(&[42; 32]); + let mut store = Store::open( + Path::new(&path), + "0.3.0-alpha.1", + key.verifying_key().to_bytes(), + ) + .unwrap(); + let bundle = marker.parent().unwrap().join("new-core"); + let (manifest, signature) = signed_bundle(&bundle, &key, "0.3.0-alpha.1", "0.3.0-alpha.2"); + let operation = uuid::Uuid::new_v4().to_string(); + let _ = store.install_with_checkpoint(&operation, &bundle, &manifest, &signature, |at| { + if at == boundary { + let file = fs::File::create(&marker).unwrap(); + file.sync_all().unwrap(); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + Ok(()) + }); + panic!("断电夹具越过了指定边界"); + } + + #[test] + fn every_switch_boundary_survives_twenty_real_process_terminations() { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for round in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + let key = SigningKey::from_bytes(&[42; 32]); + seed(&path, &temp.path().join("old"), &key); + let marker = temp.path().join(format!("{boundary}-{round}.ready")); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "core_update::tests::power_cut_worker", + "--nocapture", + ]) + .env("OPENNEXUS_A04_DATABASE", &path) + .env("OPENNEXUS_A04_BOUNDARY", boundary) + .env("OPENNEXUS_A04_MARKER", &marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + while !marker.is_file() { + assert!(child.try_wait().unwrap().is_none()); + assert!(started.elapsed() < Duration::from_secs(10)); + thread::sleep(Duration::from_millis(5)); + } + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + let reopened = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let active = reopened.active().unwrap().unwrap(); + assert_eq!(active.release.host_version, "0.3.0-alpha.1"); + assert_eq!(active.release.core_version, "0.3.0-alpha.1"); + assert!(active.pending_operation.is_none()); + } + } + } +} diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 04d5a6d..27130d1 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -4,7 +4,9 @@ use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSett use serde::Deserialize; use serde_json::{json, Value}; use std::{ - collections::HashMap, + collections::{BTreeMap, BTreeSet, HashMap}, + path::PathBuf, + sync::Arc, sync::Mutex, time::{Duration, Instant}, }; @@ -121,7 +123,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()); @@ -146,6 +148,407 @@ pub async fn extension_install_preview( .map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())? } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallConfirmation { + request_id: String, + operation_id: String, + fingerprint: String, + root_key: String, + vault_id: String, + configurations: std::collections::BTreeMap, +} + +#[tauri::command] +pub async fn extension_install_confirm( + window: WebviewWindow, + host: State<'_, Host>, + request: InstallConfirmation, +) -> Result { + main_window(&window)?; + let mut lease = host.extension_requests.claim(&request.request_id)?; + let checkpoint = lease.checkpoint(); + let workspace = host.workspace.clone(); + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + 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()); + } + let install = InstallRequest { + root_key: request.root_key, + vault_id: request.vault_id, + app_version: env!("CARGO_PKG_VERSION").into(), + platform: std::env::consts::OS.into(), + architecture: std::env::consts::ARCH.into(), + configurations: request.configurations, + }; + let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let receipt = tauri::async_runtime::block_on(lease.run(async { + checkpoint()?; + store + .as_mut() + .ok_or("EXTENSIONS_NOT_READY")? + .install_confirmed(&request.operation_id, &install, &request.fingerprint) + .await + .map_err(|error| error.code) + }))?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_INSTALL_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_INSTALL_FAILED".to_string())? +} + +#[tauri::command] +pub async fn extension_install_rollback( + window: WebviewWindow, + host: State<'_, Host>, + operation_id: String, + installed_operation_id: String, + vault_id: String, +) -> Result { + main_window(&window)?; + let workspace = host.workspace.clone(); + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?; + if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id { + return Err("VAULT_CHANGED".into()); + } + let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?; + let changes = store + .rollback_changes(&installed_operation_id) + .map_err(|error| error.code)?; + let receipt = + tauri::async_runtime::block_on(store.switch_online(&operation_id, &vault_id, &changes)) + .map_err(|error| error.code)?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_ROLLBACK_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_ROLLBACK_FAILED".to_string())? +} + +#[tauri::command] +pub fn extension_uninstall( + window: WebviewWindow, + host: State<'_, Host>, + operation_id: String, + slot: String, + expected_revision: String, +) -> Result { + main_window(&window)?; + host.extension_authority.revoke(); + #[cfg(windows)] + host.extension_instances + .lock() + .map_err(|_| "HOST_BUSY")? + .stop_all_and_join(); + #[cfg(windows)] + host.extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .clear(); + let receipt = store(&host, |s| { + s.uninstall_active(&operation_id, &slot, &expected_revision) + })?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into()) +} + +#[cfg(windows)] +type RuntimeBackend = ( + String, + Vec, + BTreeMap, +); + +#[cfg(windows)] +fn runtime_backend(manifest: &Value) -> Result { + let backend = manifest.get("backend").unwrap_or(manifest); + if backend + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind != "mcp") + || backend.get("transport").and_then(Value::as_str) != Some("stdio") + { + return Err("EXTENSION_RUNTIME_UNSUPPORTED".into()); + } + let entry = backend + .get("command") + .and_then(Value::as_str) + .ok_or("EXTENSION_ENTRY_INVALID")? + .trim_start_matches("./") + .to_owned(); + let arguments = backend + .get("args") + .and_then(Value::as_array) + .ok_or("EXTENSION_ENTRY_INVALID")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or("EXTENSION_ENTRY_INVALID".into()) + }) + .collect::, String>>()?; + let mut environment = BTreeMap::new(); + if let Some(values) = backend.get("environment") { + for (name, value) in values.as_object().ok_or("EXTENSION_ENTRY_INVALID")? { + let value = value.as_str().ok_or("EXTENSION_ENTRY_INVALID")?; + environment.insert( + name.clone(), + notesagent_host::extension_permit::Environment::Literal(value.to_owned()), + ); + } + } + Ok((entry, arguments, environment)) +} + +#[cfg(windows)] +fn rollback_pending(host: &Host, operation: Option<&str>) { + if let Some(operation) = operation { + let _ = store(host, |store| store.finish_installation(operation, false)); + } +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_enable( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + vault_id: String, + install_operation_id: Option, +) -> Result { + main_window(&window)?; + let workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id { + return Err("VAULT_CHANGED".into()); + } + drop(workspace); + let runtime = store(&host, |store| { + store.runtime_package(&slot, &vault_id, install_operation_id.as_deref()) + })?; + let (entry, arguments, environment) = match runtime_backend(&runtime.manifest) { + Ok(backend) => backend, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error); + } + }; + if !runtime.inventory.files.contains_key(&entry) { + rollback_pending(&host, install_operation_id.as_deref()); + return Err("EXTENSION_ENTRY_INVALID".into()); + } + let expires_at_ms = u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "EXTENSION_CLOCK_INVALID")? + .as_millis(), + ) + .map_err(|_| "EXTENSION_CLOCK_INVALID")? + + 8 * 60 * 60 * 1000; + let claims = notesagent_host::extension_permit::Claims { + kind: if runtime.release.kind == "plugin" { + notesagent_host::extension_permit::ExecutionKind::Plugin + } else { + notesagent_host::extension_permit::ExecutionKind::Mcp + }, + source: runtime.source.clone(), + namespace: runtime.release.namespace.clone(), + package_id: runtime.release.package_id.clone(), + version: runtime.release.version.clone(), + archive_sha256: runtime.release.sha256.clone(), + tree_sha256: runtime.active.target.tree_sha256.clone(), + signer_sha256: runtime.signer_sha256, + entry, + arguments, + environment, + permissions: runtime + .release + .permissions + .iter() + .cloned() + .collect::>(), + vault_id: vault_id.clone(), + platform: std::env::consts::OS.into(), + policy_version: "1".into(), + expires_at_ms, + }; + let permit = match host + .extension_authority + .issue(&claims, expires_at_ms - 8 * 60 * 60 * 1000) + { + Ok(permit) => permit, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error.code); + } + }; + let extensions = Arc::clone(&host.extensions); + let check_slot = slot.clone(); + let check_vault = vault_id.clone(); + let check_revision = runtime.active.revision.clone(); + let check_package = runtime.active.target.package_key.clone(); + let check_operation = install_operation_id.clone(); + let Some(system_root) = std::env::var_os("SystemRoot") else { + rollback_pending(&host, install_operation_id.as_deref()); + return Err("SYSTEM_ROOT_MISSING".into()); + }; + let spec = notesagent_host::extension_instance::LaunchSpec { + package: runtime.package, + inventory: runtime.inventory, + claims, + permit, + authority: Arc::clone(&host.extension_authority), + credentials: Arc::clone(&host.credentials), + vault_id, + policy_version: "1".into(), + system_root: PathBuf::from(system_root), + before_resume: Box::new(move |_| { + let store = extensions + .lock() + .map_err(|_| notesagent_host::workspace::HostError::new("HOST_BUSY"))?; + let current = store + .as_ref() + .ok_or_else(|| notesagent_host::workspace::HostError::new("EXTENSIONS_NOT_READY"))? + .runtime_package(&check_slot, &check_vault, check_operation.as_deref())?; + if current.active.revision != check_revision + || current.active.target.package_key != check_package + { + return Err(notesagent_host::workspace::HostError::new( + "EXTENSION_INSTALL_CONFLICT", + )); + } + Ok(()) + }), + }; + let endpoint = unsafe { + host.extension_instances + .lock() + .map_err(|_| "HOST_BUSY")? + .start(spec) + }; + let endpoint = match endpoint { + Ok(endpoint) => endpoint, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error.code); + } + }; + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = endpoint.snapshot(); + if snapshot.status == notesagent_host::extension_instance::Status::Ready { + if let Some(operation) = &install_operation_id { + store(&host, |store| store.finish_installation(operation, true))?; + } + host.extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .insert(slot, endpoint); + return serde_json::to_value(snapshot).map_err(|_| "EXTENSION_INSTANCE_INVALID".into()); + } + if snapshot.status == notesagent_host::extension_instance::Status::Failed + || Instant::now() >= deadline + { + endpoint.stop(); + rollback_pending(&host, install_operation_id.as_deref()); + return Err(snapshot + .error + .unwrap_or_else(|| "EXTENSION_START_TIMEOUT".into())); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(windows)] +#[tauri::command] +pub fn extension_instance_status( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, +) -> Result { + main_window(&window)?; + let endpoints = host.extension_endpoints.lock().map_err(|_| "HOST_BUSY")?; + let endpoint = endpoints.get(&slot).ok_or("EXTENSION_INSTANCE_NOT_READY")?; + serde_json::to_value(endpoint.snapshot()).map_err(|_| "EXTENSION_INSTANCE_INVALID".into()) +} + +#[cfg(windows)] +#[tauri::command] +pub fn extension_disable( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, +) -> Result<(), String> { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .remove(&slot) + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + endpoint.stop(); + Ok(()) +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_call_review( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + tool: String, + arguments: Value, +) -> Result { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .get(&slot) + .cloned() + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + tauri::async_runtime::spawn_blocking(move || { + let review = endpoint + .review(tool, arguments) + .map_err(|error| error.code)? + .wait(Duration::from_secs(5)) + .map_err(|error| error.code)?; + serde_json::to_value(review).map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".into()) + }) + .await + .map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".to_string())? +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_call_confirm( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + review_id: String, +) -> Result { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .get(&slot) + .cloned() + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + tauri::async_runtime::spawn_blocking(move || { + endpoint + .invoke_confirmed(review_id) + .map_err(|error| error.code)? + .wait(Duration::from_secs(65)) + .map_err(|error| error.code) + }) + .await + .map_err(|_| "EXTENSION_CALL_FAILED".to_string())? +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct StageRequest { diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 95499ec..d325ad3 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -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, @@ -678,6 +708,84 @@ mod tests { fn real_container_cannot_reach_ipv4_or_ipv6_loopback_listeners() { real_native_protocol_probes(false, false); } + #[test] + fn real_malicious_binary_cannot_read_or_write_four_ungranted_file_scopes() { + use sha2::{Digest, Sha256}; + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::*; + + let profile = Profile::create().unwrap(); + let package = tempfile::tempdir().unwrap(); + let executable = package.path().join("file-probe.exe"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sandbox_network_probe.rs"); + let compile = std::process::Command::new("rustc") + .arg("--edition=2021") + .arg(&fixture) + .arg("-o") + .arg(&executable) + .output() + .unwrap(); + assert!( + compile.status.success(), + "{}", + String::from_utf8_lossy(&compile.stderr) + ); + let open = |path: &std::path::Path| { + std::fs::OpenOptions::new() + .access_mode(READ_CONTROL | WRITE_DAC) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .unwrap() + }; + let package_root = open(package.path()); + let entry = open(&executable); + profile.grant_package_read_execute(&package_root).unwrap(); + profile.grant_package_read_execute(&entry).unwrap(); + + let bait_root = tempfile::tempdir().unwrap(); + let scopes = ["vault", "home", "credentials", "other-package"]; + let bait: Vec<_> = scopes + .iter() + .map(|scope| { + let directory = bait_root.path().join(scope); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("bait.txt"); + std::fs::write(&path, format!("OpenNexus C-01 {scope} bait")).unwrap(); + path + }) + .collect(); + let before: Vec<_> = bait + .iter() + .map(|path| Sha256::digest(std::fs::read(path).unwrap())) + .collect(); + let folder = profile.folder().unwrap(); + let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); + let mut arguments = vec!["file_denied_100".to_owned()]; + arguments.extend(bait.iter().map(|path| path.to_string_lossy().into_owned())); + let data = crate::extension_launch_data::LaunchData::new( + &executable, + &arguments, + &system, + &folder, + &folder.join("Temp"), + &std::collections::BTreeMap::new(), + ) + .unwrap(); + assert_eq!( + checked_executable_data(&profile, &executable, None, Some(data)), + Some(0) + ); + let after: Vec<_> = bait + .iter() + .map(|path| Sha256::digest(std::fs::read(path).unwrap())) + .collect(); + assert_eq!(after, before); + drop(entry); + drop(package_root); + profile.remove().unwrap(); + } #[cfg(feature = "desktop")] #[test] #[ignore = "real MCP tools/call 60-second deadline acceptance; run explicitly"] @@ -769,6 +877,12 @@ mod tests { .into_iter() .map(str::to_owned) .collect::>(); + let mut args = args; + args.extend( + (8..100).map(|index| { + format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#) + }), + ); let folder = profile.folder().unwrap(); let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); #[cfg(not(feature = "desktop"))] @@ -968,12 +1082,13 @@ mod tests { "mcp_bad_result", "mcp_idle_change", "mcp_review_lock", + "mcp_twenty", ]; if _mcp_deadline { 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 +1208,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); @@ -1183,6 +1299,18 @@ mod tests { "native MCP success" ); } + "mcp_twenty" => { + assert_eq!(result.unwrap()["structuredContent"]["ok"], true); + for _ in 1..20 { + assert_eq!( + session + .test_call_tool("echo", serde_json::json!({}), &cancel) + .unwrap()["structuredContent"]["ok"], + true + ); + } + assert!(!session.take_tools_changed()); + } _ => { assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success"); assert!(session.take_tools_changed()); @@ -1495,6 +1623,28 @@ mod tests { } } } + let descendant_listener = UdpSocket::bind("127.0.0.1:0").unwrap(); + descendant_listener.set_nonblocking(true).unwrap(); + let data = crate::extension_launch_data::LaunchData::new( + &executable, + &[ + "child_udp_100".to_owned(), + descendant_listener.local_addr().unwrap().to_string(), + ], + &system, + &folder, + &folder.join("Temp"), + &std::collections::BTreeMap::new(), + ) + .unwrap(); + assert_eq!( + checked_executable_data(&profile, &executable, None, Some(data)), + Some(0) + ); + assert_eq!( + descendant_listener.recv(&mut [0u8; 8]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); drop(entry); drop(root); profile.remove().unwrap(); diff --git a/frontend/src-tauri/src/extension_file_broker.rs b/frontend/src-tauri/src/extension_file_broker.rs index db9fac7..4c372f0 100644 --- a/frontend/src-tauri/src/extension_file_broker.rs +++ b/frontend/src-tauri/src/extension_file_broker.rs @@ -280,6 +280,17 @@ mod tests { ) .unwrap(); assert_eq!(read["content"], "original"); + for _ in 1..20 { + assert_eq!( + call( + &mut broker, + &mut ws, + json!({"method":"notes.read","path":"note.md"}), + ) + .unwrap()["content"], + "original" + ); + } let operation = uuid::Uuid::new_v4().to_string(); let write = json!({"method":"notes.write","path":"note.md","expected_hash":read["expected_hash"],"content":"extension update","operation_id":operation}); let receipt = call(&mut broker, &mut ws, write.clone()).unwrap(); @@ -395,16 +406,54 @@ mod tests { ); let outside = tempfile::tempdir().unwrap(); std::fs::hard_link(ws.root.join("note.md"), outside.path().join("alias.md")).unwrap(); - assert_eq!( - call( - &mut broker, - &mut ws, - json!({"method":"notes.read","path":"note.md"}) - ) - .unwrap_err() - .code, - "UNSAFE_PATH" - ); + for _ in 0..100 { + let mut linked = + Broker::bind(&authority, &permit, &claims, &credentials, &ws, "1", 2).unwrap(); + assert_eq!( + call( + &mut linked, + &mut ws, + json!({"method":"notes.read","path":"note.md"}) + ) + .unwrap_err() + .code, + "UNSAFE_PATH" + ); + } + let alternate = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.md"), b"outside-one").unwrap(); + std::fs::write(alternate.path().join("secret.md"), b"outside-two").unwrap(); + let redirect = ws.root.join("redirect"); + for round in 0..100 { + if redirect.exists() { + std::fs::remove_dir(&redirect).unwrap(); + } + let target = if round % 2 == 0 { + outside.path() + } else { + alternate.path() + }; + let created = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(&redirect) + .arg(target) + .output() + .unwrap(); + assert!(created.status.success()); + let mut linked = + Broker::bind(&authority, &permit, &claims, &credentials, &ws, "1", 2).unwrap(); + assert_eq!( + call( + &mut linked, + &mut ws, + json!({"method":"notes.read","path":"redirect/secret.md"}) + ) + .unwrap_err() + .code, + "UNSAFE_PATH" + ); + } + std::fs::remove_dir(&redirect).unwrap(); let second = tempfile::tempdir().unwrap(); let mut other = Workspace::open(second.path()).unwrap(); assert_eq!( diff --git a/frontend/src-tauri/src/extension_instance.rs b/frontend/src-tauri/src/extension_instance.rs index c64cc5c..dbeadee 100644 --- a/frontend/src-tauri/src/extension_instance.rs +++ b/frontend/src-tauri/src/extension_instance.rs @@ -42,7 +42,7 @@ pub struct LaunchSpec { pub claims: Claims, pub permit: Permit, pub authority: Arc, - pub credentials: Arc>, + pub credentials: Arc>>, pub vault_id: String, pub policy_version: String, pub system_root: PathBuf, @@ -152,7 +152,7 @@ pub struct Ticket { cancel: Arc, } impl Ticket { - /// Background wait only; dropping a ticket cancels its queued/in-flight work. + /// 仅供后台等待;丢弃票据会取消排队中或执行中的工作。 pub fn wait(self, timeout: Duration) -> Result { 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> { 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 { 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 @@ -373,14 +372,24 @@ impl Registry { entry.endpoint.stop(); } } -} -impl Drop for Registry { - fn drop(&mut self) { + + /// 请求停止并等待所有实例释放工具、进程、容器和包 ACL。 + pub fn stop_all_and_join(&mut self) { self.stop_all(); for (_, entry) in std::mem::take(&mut self.entries) { let _ = entry.worker.join(); } } + + pub fn active_count(&mut self) -> usize { + self.reap(); + self.entries.len() + } +} +impl Drop for Registry { + fn drop(&mut self) { + self.stop_all_and_join(); + } } fn run(spec: LaunchSpec, control: &Control, receiver: Receiver) -> Result<()> { if control.stop.load(Ordering::Acquire) { @@ -425,27 +434,47 @@ fn run_with_access( .credentials .lock() .map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?; + let credentials = credentials + .as_ref() + .ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?; context.prepare( &spec.authority, &spec.permit, &spec.claims, &entry, - &credentials, + credentials, now_ms()?, )? }; + let network = { + let credentials = spec + .credentials + .lock() + .map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?; + let credentials = credentials + .as_ref() + .ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?; + let mut lease = spec + .authority + .lease(&spec.permit, &spec.claims, now_ms()?)?; + lease.bind_credential(credentials.lock_signal()); + if credentials.is_locked() { + return Err(HostError::new("CREDENTIALS_LOCKED")); + } + crate::extension_network_broker::Broker::new(lease, &spec.claims)? + }; let (suspended, io) = prepared.create_suspended_with_stdio(profile, &entry)?; (spec.before_resume)(&spec.claims)?; 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)] { *control.job.lock().unwrap() = Some(running.test_job()?); } - let mut session = Session::new(&running, io)?; + let mut session = Session::new_with_network(&running, io, Some(network))?; session.initialize(&control.stop)?; let tools = session.refresh_tools(&control.stop)?; *control.identity.lock().unwrap_or_else(|e| e.into_inner()) = Some(running.call_identity()?); @@ -457,6 +486,9 @@ fn run_with_access( .status .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire); while !control.stop.load(Ordering::Acquire) { + // 撤销、锁定和工作区切换必须在空闲实例上也能生效,不能等待下一次工具调用。 + spec.authority + .verify(&spec.permit, &spec.claims, now_ms()?)?; session.drain_pending()?; if session.take_tools_changed() { control @@ -512,6 +544,7 @@ mod tests { fn native_worker_routes_reviews_cancels_calls_and_reaps_generations() { native_worker_lifecycle(false); } + #[test] #[ignore = "real background MCP CPU/memory/process exhaustion and restart; run explicitly"] fn native_resource_failures_are_reaped_and_replacements_can_start() { @@ -554,12 +587,14 @@ mod tests { }; let tree = crate::extension_unpack::verify_tree(&dir, &inventory()).unwrap(); let authority = Arc::new(Authority::default()); - let credentials = Arc::new(Mutex::new(CredentialBroker::new( + let credentials = Arc::new(Mutex::new(Some(CredentialBroker::new( temp.path().join("credentials.v1"), - ))); + )))); credentials .lock() .unwrap() + .as_mut() + .unwrap() .unlock(Zeroizing::new(b"instance fixture password".to_vec())) .unwrap(); let vault_id = uuid::Uuid::new_v4().to_string(); @@ -576,7 +611,13 @@ mod tests { entry: "entry.exe".into(), arguments: vec![mode.into()], environment: BTreeMap::new(), - permissions: Default::default(), + permissions: if mode == "mcp_network_denied" { + ["network.https:https://127.0.0.1/".into()] + .into_iter() + .collect() + } else { + Default::default() + }, vault_id: vault_id.clone(), platform: "windows".into(), policy_version: "1".into(), @@ -634,17 +675,25 @@ mod tests { .code, "EXTENSION_CALL_REVIEW_UNKNOWN" ); - endpoint.stop(); + // 空闲实例也必须在许可撤销后自行退出并清空工具注册。 + let revoked_at = Instant::now(); + authority.revoke(); wait_for(|| { registry.reap(); registry.entries.is_empty() }); assert_eq!( endpoint.snapshot().status, - Status::Stopped, + Status::Failed, "{:?}", endpoint.snapshot().error ); + assert_eq!(endpoint.snapshot().tool_count, 0); + assert!(revoked_at.elapsed() < Duration::from_secs(5)); + assert_eq!( + endpoint.snapshot().error.as_deref(), + Some("EXTENSION_PERMIT_REVOKED") + ); assert!(endpoint.review("echo".into(), json!({})).is_err()); assert_eq!( endpoint @@ -658,6 +707,24 @@ mod tests { .unwrap(), 0 ); + let denied_network = unsafe { registry.start(make("mcp_network_denied")) }.unwrap(); + wait_for(|| denied_network.snapshot().status != Status::Starting); + assert_eq!(denied_network.snapshot().status, Status::Ready); + let review = denied_network + .review("echo".into(), json!({})) + .unwrap() + .wait(Duration::from_secs(5)) + .unwrap(); + assert!(denied_network + .invoke_confirmed(review.review_id) + .unwrap() + .wait(Duration::from_secs(5)) + .is_ok()); + denied_network.stop(); + wait_for(|| { + registry.reap(); + registry.entries.is_empty() + }); let second = unsafe { registry.start(make("mcp_cancel")) }.unwrap(); wait_for(|| second.snapshot().status != Status::Starting); assert_eq!( @@ -736,6 +803,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() @@ -839,7 +907,7 @@ mod tests { let locked = unsafe { registry.start(make("mcp")) }.unwrap(); wait_for(|| locked.snapshot().status != Status::Starting); assert_eq!(locked.snapshot().status, Status::Ready); - credentials.lock().unwrap().lock(); + credentials.lock().unwrap().as_mut().unwrap().lock(); wait_for(|| { registry.reap(); registry.entries.is_empty() diff --git a/frontend/src-tauri/src/extension_io.rs b/frontend/src-tauri/src/extension_io.rs index 720cc29..5e84cff 100644 --- a/frontend/src-tauri/src/extension_io.rs +++ b/frontend/src-tauri/src/extension_io.rs @@ -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) -> 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, 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); } } diff --git a/frontend/src-tauri/src/extension_job.rs b/frontend/src-tauri/src/extension_job.rs index 3823d25..4262a0a 100644 --- a/frontend/src-tauri/src/extension_job.rs +++ b/frontend/src-tauri/src/extension_job.rs @@ -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::with_process_limit(16) + Self::with_process_limit(16, None) } - fn with_process_limit(processes: u32) -> Result { + pub fn with_scratch(scratch: &Path) -> Result { + 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) -> Result { 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, worker: Option>, } impl ResourceMonitor { - fn arm(job: &Job) -> Result { + fn arm(job: &Job, scratch: Option) -> Result { 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 { + 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!( diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs index 311ff95..b217f9a 100644 --- a/frontend/src-tauri/src/extension_launch_authorization.rs +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -80,7 +80,8 @@ impl PreparedLaunch { } } impl<'a> LeasedSuspended<'a> { - /// # Safety Live 信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。 + /// # Safety + /// 实时信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。 pub unsafe fn resume(self) -> Result> { 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 { if reference.is_empty() || reference.len() > 128 @@ -132,8 +132,7 @@ pub fn credential_id(claims: &Claims, reference: &str) -> Result { }) } 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()); } } diff --git a/frontend/src-tauri/src/extension_launch_data.rs b/frontend/src-tauri/src/extension_launch_data.rs index aabb844..914f908 100644 --- a/frontend/src-tauri/src/extension_launch_data.rs +++ b/frontend/src-tauri/src/extension_launch_data.rs @@ -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, environment: Vec, + 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 = 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,42 @@ 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")); + } + + #[test] + fn shell_arguments_and_environment_injection_pass_hundred_round_matrix() { + let arguments = (0..100) + .map(|index| format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#)) + .collect::>(); + let mut launch = build(&arguments, &BTreeMap::new()).unwrap(); + let encoded = launch.command_mut(); + assert!(encoded + .windows(9) + .any(|value| value == "attack-99".encode_utf16().collect::>())); + + for index in 0..100 { + let name = match index % 5 { + 0 => "TEMP".to_owned(), + 1 => "tmp".to_owned(), + 2 => "SystemRoot".to_owned(), + 3 => "LOCALAPPDATA".to_owned(), + _ => format!("BAD={index}"), + }; + assert_eq!( + build(&[], &BTreeMap::from([(name, "injected".into())])) + .err() + .unwrap() + .code, + "EXTENSION_LAUNCH_DATA_INVALID" + ); + } + } } diff --git a/frontend/src-tauri/src/extension_legacy.rs b/frontend/src-tauri/src/extension_legacy.rs new file mode 100644 index 0000000..6d271a7 --- /dev/null +++ b/frontend/src-tauri/src/extension_legacy.rs @@ -0,0 +1,377 @@ +//! 旧 Python 扩展安装库的只读接管。 +//! +//! 导入只记录来源状态,不复制或删除旧包,也不继承启用意图、信任与许可。 + +use crate::workspace::{HostError, Result}; +use rusqlite::{params, Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +const MAX_RECORDS: usize = 4096; +const MAX_PACKAGE_BYTES: u64 = 50 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 4096; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct LegacyRecord { + path: String, + digest: String, + #[serde(default)] + enabled: bool, + #[serde(default)] + permissions: Vec, + managed_root: Option, + #[serde(default)] + removed: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LegacyImport { + pub kind: String, + pub package_id: String, + pub source_path: String, + pub expected_digest: String, + pub observed_digest: Option, + pub ownership: String, + pub state: String, + pub enabled: bool, + pub permissions: Vec, +} + +fn digest_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn collect_files(root: &Path, directory: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + } + if metadata.is_dir() { + collect_files(root, &path, files)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| HostError::new("EXTENSION_LEGACY_UNSAFE"))?; + if !relative + .components() + .any(|part| part.as_os_str() == "__pycache__") + && path.extension().and_then(|value| value.to_str()) != Some("pyc") + { + files.push(path); + if files.len() > MAX_PACKAGE_FILES { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + } + } + } + Ok(()) +} + +fn package_digest(root: &Path) -> Result { + let root = root + .canonicalize() + .map_err(|_| HostError::new("EXTENSION_LEGACY_MISSING"))?; + if !root.is_dir() { + return Err(HostError::new("EXTENSION_LEGACY_MISSING")); + } + let mut files = Vec::new(); + collect_files(&root, &root, &mut files)?; + files.sort_by_key(|path| { + path.strip_prefix(&root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }); + let mut total = 0_u64; + let mut digest = Sha256::new(); + for path in files { + let relative = path + .strip_prefix(&root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + digest.update(relative.as_bytes()); + digest.update([0]); + total = total.saturating_add(path.metadata()?.len()); + if total > MAX_PACKAGE_BYTES { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + let mut file = File::open(path)?; + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + } + Ok(format!("{:x}", digest.finalize())) +} + +fn normal_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +fn classify(record: &LegacyRecord, managed_storage: &Path) -> Result { + let source = PathBuf::from(&record.path); + if !source.is_absolute() + || record.digest.len() != 64 + || !record.digest.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let managed = record.managed_root.as_ref().is_some_and(|raw| { + let root = PathBuf::from(raw); + root.is_absolute() && root.parent() == Some(managed_storage) && source.starts_with(root) + }); + let observed = if source.is_dir() { + Some(package_digest(&source)?) + } else { + None + }; + let state = if record.removed { + "removed" + } else if observed.is_none() { + "missing" + } else if observed.as_deref() != Some(record.digest.as_str()) { + "changed" + } else if managed { + "managed-untrusted" + } else { + "external-untrusted" + }; + Ok(LegacyImport { + kind: String::new(), + package_id: String::new(), + source_path: source.to_string_lossy().into_owned(), + expected_digest: record.digest.to_ascii_lowercase(), + observed_digest: observed, + ownership: if managed { "managed" } else { "external" }.to_string(), + state: state.to_string(), + enabled: false, + permissions: Vec::new(), + }) +} + +pub fn import( + db: &mut Connection, + host_root: &Path, + legacy_data_root: &Path, +) -> Result> { + let source = legacy_data_root.join("extension-installations.sqlite3"); + if !source.exists() { + return Ok(Vec::new()); + } + if fs::symlink_metadata(&source)?.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + let before = digest_file(&source)?; + let legacy = Connection::open_with_flags( + &source, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let table: i64 = legacy.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='installations'", + [], + |row| row.get(0), + )?; + if table != 1 { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let mut statement = + legacy.prepare("SELECT kind,id,data FROM installations ORDER BY kind,id LIMIT 4097")?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut imported = Vec::new(); + let managed_storage = legacy_data_root.join("extension-packages"); + for row in rows { + if imported.len() == MAX_RECORDS { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + let (kind, package_id, raw) = row?; + if !matches!(kind.as_str(), "skill" | "plugin") + || !normal_id(&package_id) + || raw.len() > 1024 * 1024 + { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let record: LegacyRecord = + serde_json::from_str(&raw).map_err(|_| HostError::new("EXTENSION_LEGACY_INVALID"))?; + let mut item = classify(&record, &managed_storage)?; + item.kind = kind; + item.package_id = package_id; + imported.push(item); + } + drop(statement); + drop(legacy); + if digest_file(&source)? != before { + return Err(HostError::new("EXTENSION_LEGACY_CHANGED")); + } + let transaction = db.transaction()?; + for item in &imported { + transaction.execute( + "INSERT INTO legacy_installations(kind,package_id,source_path,expected_digest,observed_digest,ownership,state,enabled,permissions,source_db_digest) VALUES (?1,?2,?3,?4,?5,?6,?7,0,'[]',?8) ON CONFLICT(kind,package_id) DO UPDATE SET source_path=excluded.source_path,expected_digest=excluded.expected_digest,observed_digest=excluded.observed_digest,ownership=excluded.ownership,state=excluded.state,enabled=0,permissions='[]',source_db_digest=excluded.source_db_digest", + params![item.kind,item.package_id,item.source_path,item.expected_digest,item.observed_digest,item.ownership,item.state,before])?; + } + transaction.commit()?; + let marker = legacy_data_root.join("extension-installations.rust-owned.json"); + let marker_data = serde_json::to_vec(&serde_json::json!({"schema":1,"owner":"rust-host","source_db_sha256":before,"host_root":host_root.to_string_lossy()})).unwrap(); + let temporary = legacy_data_root.join("extension-installations.rust-owned.tmp"); + { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary)?; + file.write_all(&marker_data)?; + file.sync_all()?; + } + fs::rename(temporary, marker)?; + Ok(imported) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_package(path: &Path, content: &[u8]) -> String { + fs::create_dir_all(path).unwrap(); + fs::write(path.join("entry.py"), content).unwrap(); + package_digest(path).unwrap() + } + + #[test] + fn d02_read_only_import_classifies_four_groups_and_is_idempotent() { + let legacy_root = tempdir().unwrap(); + let host_root = tempdir().unwrap(); + let managed_root = legacy_root.path().join("extension-packages/managed-a"); + let managed_package = managed_root.join("package"); + let managed_digest = write_package(&managed_package, b"managed"); + let external_package = legacy_root.path().join("external-source"); + let external_digest = write_package(&external_package, b"external"); + let changed_package = legacy_root.path().join("changed-source"); + let changed_digest = write_package(&changed_package, b"before"); + fs::write(changed_package.join("entry.py"), b"after").unwrap(); + let missing_package = legacy_root.path().join("missing-source"); + + let legacy_db_path = legacy_root.path().join("extension-installations.sqlite3"); + let legacy_db = Connection::open(&legacy_db_path).unwrap(); + legacy_db + .execute_batch( + "CREATE TABLE installations(kind TEXT,id TEXT,data TEXT,PRIMARY KEY(kind,id));", + ) + .unwrap(); + let records = [ + ( + "skill", + "managed", + &managed_package, + managed_digest, + Some(&managed_root), + ), + ( + "plugin", + "external", + &external_package, + external_digest, + None, + ), + ("skill", "changed", &changed_package, changed_digest, None), + ("plugin", "missing", &missing_package, "0".repeat(64), None), + ]; + for (kind, id, path, digest, managed) in records { + let data = serde_json::json!({ + "path": path.to_string_lossy(), "digest": digest, "enabled": true, + "permissions": ["notes.write"], + "managed_root": managed.map(|value| value.to_string_lossy().into_owned()), + "removed": false, + }); + legacy_db + .execute( + "INSERT INTO installations VALUES (?1,?2,?3)", + params![kind, id, data.to_string()], + ) + .unwrap(); + } + drop(legacy_db); + let source_before = digest_file(&legacy_db_path).unwrap(); + let external_before = package_digest(&external_package).unwrap(); + + let mut host_db = Connection::open(host_root.path().join("host.sqlite3")).unwrap(); + host_db.execute_batch("CREATE TABLE legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id));").unwrap(); + for _ in 0..3 { + let result = import(&mut host_db, host_root.path(), legacy_root.path()).unwrap(); + assert_eq!(result.len(), 4); + assert!(result + .iter() + .all(|item| !item.enabled && item.permissions.is_empty())); + } + let states: Vec<(String, String)> = host_db + .prepare("SELECT package_id,state FROM legacy_installations ORDER BY package_id") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .map(std::result::Result::unwrap) + .collect(); + assert_eq!( + states, + vec![ + ("changed".into(), "changed".into()), + ("external".into(), "external-untrusted".into()), + ("managed".into(), "managed-untrusted".into()), + ("missing".into(), "missing".into()), + ] + ); + assert_eq!( + host_db + .query_row("SELECT COUNT(*) FROM legacy_installations", [], |row| row + .get::<_, i64>( + 0 + )) + .unwrap(), + 4 + ); + assert_eq!(digest_file(&legacy_db_path).unwrap(), source_before); + assert_eq!(package_digest(&external_package).unwrap(), external_before); + assert!(legacy_root + .path() + .join("extension-installations.rust-owned.json") + .is_file()); + } +} diff --git a/frontend/src-tauri/src/extension_mcp.rs b/frontend/src-tauri/src/extension_mcp.rs index 8642977..d769084 100644 --- a/frontend/src-tauri/src/extension_mcp.rs +++ b/frontend/src-tauri/src/extension_mcp.rs @@ -78,7 +78,7 @@ fn decode(bytes: &[u8]) -> Result { { return Err(invalid()); } - // Error data is never propagated or logged; it may contain secrets. + // 错误数据可能包含秘密,因此绝不传播或记录。 let _ = &error.data; } Ok(value) @@ -93,9 +93,17 @@ pub struct Session<'a, 'p> { tools_changed: bool, catalog: Option, calls: crate::extension_call_authorization::Gate, + network: Option, } impl<'a, 'p> Session<'a, 'p> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result { + Self::new_with_network(process, io, None) + } + pub fn new_with_network( + process: &'a Running<'p>, + io: HostIo, + network: Option, + ) -> Result { Ok(Self { process, pump: process.start_io(io)?, @@ -106,6 +114,7 @@ impl<'a, 'p> Session<'a, 'p> { tools_changed: false, catalog: None, calls: crate::extension_call_authorization::Gate::new(process.call_identity()?), + network, }) } pub fn initialize(&mut self, cancel: &AtomicBool) -> Result { @@ -217,8 +226,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, @@ -303,8 +312,30 @@ impl<'a, 'p> Session<'a, 'p> { return Ok(false); }; if let Some(id) = &message.id { - self.send(if method == "ping" { json!({"jsonrpc":"2.0","id":id,"result":{}}) } - else { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) })?; + let response = if method == "ping" { + json!({"jsonrpc":"2.0","id":id,"result":{}}) + } else if method == "opennexus/network.fetch" { + let result = message + .params + .clone() + .ok_or_else(invalid) + .and_then(|params| serde_json::from_value(params).map_err(|_| invalid())) + .and_then(|request| { + self.network + .as_mut() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED"))? + .fetch(request) + }); + match result { + Ok(value) => json!({"jsonrpc":"2.0","id":id,"result":value}), + Err(error) => { + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error.code}}) + } + } + } else { + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) + }; + self.send(response)?; } else if method == "notifications/tools/list_changed" { self.tools_changed = true; self.catalog = None; @@ -312,8 +343,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 +403,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}})); } diff --git a/frontend/src-tauri/src/extension_network_broker.rs b/frontend/src-tauri/src/extension_network_broker.rs new file mode 100644 index 0000000..b2bac20 --- /dev/null +++ b/frontend/src-tauri/src/extension_network_broker.rs @@ -0,0 +1,405 @@ +//! 由 Host 执行的扩展 HTTPS 代理。沙箱进程本身始终不获得网络能力。 +use crate::{ + extension_permit::{Claims, Lease}, + workspace::{HostError, Result}, +}; +use reqwest::{blocking::Client, redirect::Policy, Method, Url}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + io::Read, + net::{IpAddr, SocketAddr, ToSocketAddrs}, + time::{Duration, Instant}, +}; + +const PERMISSION_PREFIX: &str = "network.https:"; +const ADDRESS_PREFIX: &str = "network.https-address:"; +const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const MAX_CALLS_PER_MINUTE: usize = 60; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FetchRequest { + pub url: String, + #[serde(default = "default_method")] + pub method: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub content_type: Option, +} + +fn default_method() -> String { + "GET".into() +} + +#[derive(Serialize)] +pub struct FetchResponse { + pub status: u16, + pub body: String, + pub content_type: Option, +} + +pub struct Broker { + lease: Lease, + origins: BTreeSet, + addresses: BTreeMap>, + calls: Vec, +} + +impl Broker { + pub fn new(lease: Lease, claims: &Claims) -> Result { + let mut origins = BTreeSet::new(); + let mut addresses: BTreeMap> = BTreeMap::new(); + for permission in &claims.permissions { + if let Some(value) = permission.strip_prefix(PERMISSION_PREFIX) { + origins.insert(canonical_origin(value)?); + } else if let Some(value) = permission.strip_prefix(ADDRESS_PREFIX) { + let (host, address) = value + .split_once('=') + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + let normalized = normalized_host(host)?; + let address: IpAddr = address + .parse() + .map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + if prohibited(address) { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); + } + addresses.entry(normalized).or_default().push(address); + } + } + if addresses + .keys() + .any(|host| !origins.iter().any(|allowed| origin_host(allowed) == host)) + { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); + } + Ok(Self { + lease, + origins, + addresses, + calls: Vec::new(), + }) + } + + pub fn fetch(&mut self, request: FetchRequest) -> Result { + self.lease.check()?; + let now = Instant::now(); + self.calls + .retain(|called| now.duration_since(*called) < Duration::from_secs(60)); + if self.calls.len() >= MAX_CALLS_PER_MINUTE { + return Err(HostError::new("EXTENSION_NETWORK_RATE_LIMITED")); + } + self.calls.push(now); + + if request.url.len() > 4096 + || request.body.len() > MAX_REQUEST_BYTES + || request + .content_type + .as_ref() + .is_some_and(|value| value.len() > 128 || value.chars().any(char::is_control)) + { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + let url = Url::parse(&request.url) + .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + validate_url(&url)?; + if !self.origins.contains(&origin(&url)?) { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED")); + } + let host = url + .host_str() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let addresses: Vec = if let Some(pinned) = self.addresses.get(host) { + pinned + .iter() + .map(|address| SocketAddr::new(*address, port)) + .collect() + } else { + (host, port) + .to_socket_addrs() + .map_err(|_| HostError::new("EXTENSION_NETWORK_DNS_FAILED"))? + .collect() + }; + validate_addresses(&addresses)?; + + let client = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .resolve_to_addrs(host, &addresses) + .build() + .map_err(|_| HostError::new("EXTENSION_NETWORK_UNAVAILABLE"))?; + let method = match request.method.as_str() { + "GET" => Method::GET, + "POST" => Method::POST, + _ => return Err(HostError::new("EXTENSION_NETWORK_METHOD_DENIED")), + }; + if method == Method::GET && !request.body.is_empty() { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + let mut builder = client.request(method, url); + if !request.body.is_empty() { + builder = builder.body(request.body); + } + if let Some(content_type) = request.content_type { + builder = builder.header(reqwest::header::CONTENT_TYPE, content_type); + } + let response = builder + .send() + .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_FAILED"))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.chars().take(128).collect()); + let mut bytes = Vec::new(); + response + .take((MAX_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; + if bytes.len() > MAX_RESPONSE_BYTES { + return Err(HostError::new("EXTENSION_NETWORK_RESPONSE_TOO_LARGE")); + } + let body = String::from_utf8(bytes) + .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; + self.lease.check()?; + Ok(FetchResponse { + status, + body, + content_type, + }) + } +} + +fn validate_addresses(addresses: &[SocketAddr]) -> Result<()> { + if addresses.is_empty() || addresses.iter().any(|address| prohibited(address.ip())) { + return Err(HostError::new("EXTENSION_NETWORK_ADDRESS_DENIED")); + } + Ok(()) +} + +fn canonical_origin(value: &str) -> Result { + let url = + Url::parse(value).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + validate_url(&url).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); + } + origin(&url) +} + +fn origin(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + Ok(format!("https://{host}:{port}")) +} + +fn origin_host(origin: &str) -> &str { + origin + .strip_prefix("https://") + .unwrap_or(origin) + .rsplit_once(':') + .map_or(origin, |(host, _)| host) +} + +fn normalized_host(value: &str) -> Result { + if value.is_empty() || value.contains(['/', '@', ':', '[', ']']) { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); + } + let url = Url::parse(&format!("https://{value}/")) + .map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + url.host_str() + .filter(|host| *host == value.to_ascii_lowercase()) + .map(str::to_owned) + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")) +} + +fn validate_url(url: &Url) -> Result<()> { + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + Ok(()) +} + +fn prohibited(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || octets[0] == 0 + || octets[0] >= 224 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + let first = segments[0]; + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (first & 0xe000) != 0x2000 + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || ip + .to_ipv4_mapped() + .is_some_and(|mapped| prohibited(mapped.into())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension_permit::{Authority, ExecutionKind}; + use std::collections::BTreeMap; + + fn claims(origin: &str, address: Option<&str>) -> Claims { + let host = Url::parse(origin).unwrap().host_str().unwrap().to_owned(); + let mut permissions = BTreeSet::from([format!("{PERMISSION_PREFIX}{origin}")]); + if let Some(address) = address { + permissions.insert(format!("{ADDRESS_PREFIX}{host}={address}")); + } + Claims { + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "network-probe".into(), + version: "1.0.0".into(), + archive_sha256: "a".repeat(64), + tree_sha256: "b".repeat(64), + signer_sha256: "c".repeat(64), + entry: "probe.exe".into(), + arguments: Vec::new(), + environment: BTreeMap::new(), + permissions, + vault_id: uuid::Uuid::new_v4().to_string(), + platform: "windows".into(), + policy_version: "1".into(), + expires_at_ms: 120_000, + } + } + + #[test] + fn only_exact_https_origins_are_accepted() { + assert_eq!( + canonical_origin("https://example.com/").unwrap(), + "https://example.com:443" + ); + assert_eq!( + canonical_origin("https://example.com:8443/").unwrap(), + "https://example.com:8443" + ); + for value in [ + "http://example.com/", + "https://user@example.com/", + "https://example.com/path", + ] { + assert_eq!( + canonical_origin(value).unwrap_err().code, + "EXTENSION_NETWORK_PERMISSION_INVALID" + ); + } + } + + #[test] + fn local_metadata_and_special_addresses_are_denied() { + for value in [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "100.64.0.1", + "169.254.169.254", + "192.0.2.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "0.0.0.0", + "::1", + "fc00::1", + "fe80::1", + "2001:db8::1", + ] { + assert!(prohibited(value.parse().unwrap()), "{value}"); + } + assert!(!prohibited("8.8.8.8".parse().unwrap())); + assert!(!prohibited("2606:4700:4700::1111".parse().unwrap())); + for _ in 0..100 { + let rebound = [ + "160.202.254.170:443".parse().unwrap(), + "169.254.169.254:443".parse().unwrap(), + ]; + assert_eq!( + validate_addresses(&rebound).unwrap_err().code, + "EXTENSION_NETWORK_ADDRESS_DENIED" + ); + } + } + + #[test] + #[ignore = "需要公开 DNS 和 TLS;由 C-02 生产验收驱动显式执行"] + fn authorized_public_https_and_redirect_policy_pass_production_matrix() { + let authority = Authority::default(); + let claims = claims("https://acm.kronecker.cc:18443/", Some("160.202.254.170")); + let make = || { + let lease = authority + .lease(&authority.issue(&claims, 1).unwrap(), &claims, 1) + .unwrap(); + Broker::new(lease, &claims).unwrap() + }; + let mut broker = make(); + for _ in 0..20 { + let response = broker + .fetch(FetchRequest { + url: "https://acm.kronecker.cc:18443/ok".into(), + method: "GET".into(), + body: String::new(), + content_type: None, + }) + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body, "OpenNexus C-02 controlled TLS endpoint"); + } + for _ in 0..2 { + let mut broker = make(); + for _ in 0..50 { + let response = broker + .fetch(FetchRequest { + url: "https://acm.kronecker.cc:18443/redirect".into(), + method: "GET".into(), + body: String::new(), + content_type: None, + }) + .unwrap(); + assert_eq!(response.status, 302); + assert!(response.body.is_empty()); + } + } + } +} diff --git a/frontend/src-tauri/src/extension_pinned.rs b/frontend/src-tauri/src/extension_pinned.rs index c1a2b1a..76c9e0d 100644 --- a/frontend/src-tauri/src/extension_pinned.rs +++ b/frontend/src-tauri/src/extension_pinned.rs @@ -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, diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index 88c738b..5859807 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -96,9 +96,8 @@ pub struct Running<'a> { identity: Option, } 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::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 = 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::() 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> { self.0.job.check_resources()?; if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { diff --git a/frontend/src-tauri/src/extension_stdio.rs b/frontend/src-tauri/src/extension_stdio.rs index c9658be..69dfbf4 100644 --- a/frontend/src-tauri/src/extension_stdio.rs +++ b/frontend/src-tauri/src/extension_stdio.rs @@ -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 { 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); diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 40d1163..c9cbf83 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -100,6 +100,15 @@ pub struct InstallPreview { pub dependencies: crate::extension_dependencies::Plan, pub changes: Vec, } +pub struct RuntimePackage { + pub package: cap_std::fs::Dir, + pub inventory: crate::extension_package::Inventory, + pub release: Release, + pub manifest: serde_json::Value, + pub active: crate::extension_transaction::Active, + pub source: String, + pub signer_sha256: String, +} pub struct ExtensionStore { root: PathBuf, db: Connection, @@ -288,10 +297,10 @@ impl ExtensionStore { let mut db = Connection::open(database)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 6 { + if version > 7 { return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); } - if (1..6).contains(&version) { + if (1..7).contains(&version) { let backup = root.join(format!( "extensions.schema{version}.{}.sqlite3", Uuid::new_v4() @@ -308,7 +317,9 @@ impl ExtensionStore { CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id)); CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL); CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL); - PRAGMA user_version=6; COMMIT;")?; + CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id)); + PRAGMA user_version=7; COMMIT;")?; crate::extension_transaction::recover(&mut db)?; Ok(Self { root, @@ -316,6 +327,14 @@ impl ExtensionStore { _lock: lock, }) } + + /// 只读导入旧 Python 安装记录;导入结果始终禁用、无许可且未受信任。 + pub fn import_legacy_installations( + &mut self, + legacy_data_root: &Path, + ) -> Result> { + crate::extension_legacy::import(&mut self.db, &self.root, legacy_data_root) + } pub fn trust_setting( &self, source_url: &str, @@ -840,6 +859,107 @@ impl ExtensionStore { ) -> Result> { crate::extension_transaction::active(&self.db, slot) } + + /// 重新验证活动指针、签名、信任、配置和展开树,并返回只能由 Host 消费的运行材料。 + pub fn runtime_package( + &self, + slot: &str, + vault_id: &str, + pending_operation: Option<&str>, + ) -> Result { + use cap_fs_ext::DirExt; + let vault = Uuid::parse_str(vault_id) + .map_err(|_| HostError::new("VAULT_INVALID"))? + .to_string(); + if vault != vault_id { + return Err(HostError::new("VAULT_INVALID")); + } + let active = self + .active_installation(slot)? + .filter(|item| item.pending_operation.as_deref() == pending_operation) + .ok_or_else(|| HostError::new("EXTENSION_NOT_ACTIVE"))?; + let (source, release_json, signer, manifest_json, directory, tree): ( + String, + String, + Vec, + String, + String, + String, + ) = self.db.query_row( + "SELECT v.source,v.release,v.signer,v.manifest,p.directory,p.tree_sha256 FROM versions v JOIN prepared_packages p ON p.package_key=v.package_key WHERE v.package_key=?1", + [&active.target.package_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)), + )?; + let release: Release = serde_json::from_str(&release_json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let public: [u8; 32] = signer + .try_into() + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let expected_slot = hash( + &serde_json::to_vec(&(&vault, &source, &release.namespace, &release.package_id)) + .unwrap(), + ); + if slot != expected_slot + || active.target.directory != directory + || active.target.tree_sha256 != tree + { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + let trusted = self + .trust_setting(&source, &release.namespace, &release.key_id)? + .ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?; + if !trusted.enabled || trusted.public_key != public { + return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED")); + } + self.check_not_revoked(&source, &release, &public)?; + let archive = self.archive(&active.target.package_key)?; + let (inventory, verified_manifest) = release.verify_package( + &public, + &release.key_id, + &release.namespace, + false, + false, + &archive, + )?; + if verified_manifest != manifest { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + crate::extension_config::validate(&manifest, &active.target.configuration)?; + let root = cap_std::fs::Dir::open_ambient_dir(&self.root, cap_std::ambient_authority())?; + let package = root + .open_dir_nofollow("prepared")? + .open_dir_nofollow(&directory)?; + if crate::extension_unpack::verify_tree(&package, &inventory)? != tree { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + Ok(RuntimePackage { + package, + inventory, + release, + manifest, + active, + source, + signer_sha256: hash(&public), + }) + } + + pub fn rollback_changes( + &self, + operation: &str, + ) -> Result> { + crate::extension_transaction::rollback_changes(&self.db, operation) + } + + pub fn uninstall_active( + &mut self, + operation: &str, + slot: &str, + expected_revision: &str, + ) -> Result { + crate::extension_transaction::uninstall(&mut self.db, operation, slot, expected_revision) + } /// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。 pub fn prepare( &mut self, @@ -1453,6 +1573,80 @@ mod tests { 0 ); } + #[tokio::test] + async fn offline_new_install_is_rejected_before_creating_a_transaction() { + use crate::extension_transaction::{Change, Target}; + let temp = tempfile::tempdir().unwrap(); + let (release, archive, key) = fixture(); + let source = "https://127.0.0.1:9/"; + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let staged = store + .stage(Stage { + operation_id: &Uuid::new_v4().to_string(), + source, + signer: Signer { + public_key: &key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }, + release: &release, + withdrawn: false, + archive: &archive, + }) + .unwrap(); + let setting = TrustSetting { + source: source.into(), + source_id: "offline-catalog".into(), + namespace: "examples".into(), + key_id: "test-key".into(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + let prepared = store + .prepare( + &staged.package_key, + Signer { + public_key: &key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }, + false, + ) + .unwrap(); + let vault = Uuid::new_v4().to_string(); + let slot = hash( + &serde_json::to_vec(&(&vault, source, &release.namespace, &release.package_id)) + .unwrap(), + ); + let changes = [Change { + target: Target { + slot, + package_key: staged.package_key, + directory: prepared.directory, + tree_sha256: prepared.tree_sha256, + configuration: serde_json::json!({}), + }, + expected_revision: None, + }]; + let error = store + .switch_online(&Uuid::new_v4().to_string(), &vault, &changes) + .await + .unwrap_err(); + assert_eq!(error.code, "EXTENSION_TRUST_UNAVAILABLE"); + assert_eq!( + store + .db + .query_row("SELECT COUNT(*) FROM extension_transactions", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } #[test] fn confirmed_trust_survives_reopen_and_rotation_requires_matching_review() { let temp = tempfile::tempdir().unwrap(); @@ -1594,6 +1788,20 @@ mod tests { let operation = Uuid::new_v4().to_string(); store.switch_prepared(&operation, &vault, &changes).unwrap(); store.finish_installation(&operation, true).unwrap(); + let setting = TrustSetting { + source: "https://catalog.example/".into(), + source_id: "catalog".into(), + namespace: release.namespace.clone(), + key_id: release.key_id.clone(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + let runtime = store.runtime_package(&slot, &vault, None).unwrap(); + assert_eq!(runtime.active.target, changes[0].target); + assert_eq!(runtime.release.package_id, release.package_id); drop(store); let mut store = ExtensionStore::open(temp.path()).unwrap(); assert_eq!( diff --git a/frontend/src-tauri/src/extension_transaction.rs b/frontend/src-tauri/src/extension_transaction.rs index 937dcff..82c804c 100644 --- a/frontend/src-tauri/src/extension_transaction.rs +++ b/frontend/src-tauri/src/extension_transaction.rs @@ -31,7 +31,8 @@ pub struct Receipt { } pub fn schema(db: &Connection) -> Result<()> { db.execute_batch("CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT); - CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);")?; + CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?; Ok(()) } pub fn active(db: &Connection, slot: &str) -> Result> { @@ -209,6 +210,95 @@ pub fn recover(db: &mut Connection) -> Result { Ok(ids.len()) } +/// 为已完成的升级生成回滚变更;回滚本身仍以新的 operation_id 执行和记录。 +pub fn rollback_changes(db: &Connection, operation: &str) -> Result> { + let (before, after, state): (String, String, String) = db + .query_row( + "SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|_| HostError::new("EXTENSION_ROLLBACK_UNKNOWN"))?; + if state != "complete" { + return Err(HostError::new("EXTENSION_ROLLBACK_INVALID")); + } + let old: Vec> = + serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let installed: Vec = + serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + if old.len() != installed.len() || old.iter().any(Option::is_none) { + return Err(HostError::new("EXTENSION_ROLLBACK_INVALID")); + } + old.into_iter() + .zip(installed) + .map(|(previous, installed)| { + let previous = previous.unwrap(); + let current = active(db, &installed.target.slot)? + .ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?; + if current.pending_operation.is_some() || current.target != installed.target { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + Ok(Change { + target: previous.target, + expected_revision: Some(current.revision), + }) + }) + .collect() +} + +/// 实例停止后原子移除活动指针。保留已验证对象,以便审计或显式回滚;不会触碰外部目录。 +pub fn uninstall( + db: &mut Connection, + operation: &str, + slot: &str, + expected_revision: &str, +) -> Result { + if uuid::Uuid::parse_str(operation).is_err() + || slot.len() != 64 + || expected_revision.len() != 64 + || [slot, expected_revision].iter().any(|value| { + !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) + { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + db.execute_batch("CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?; + let transaction = db.transaction()?; + let prior: Option<(String, String, String)> = transaction + .query_row( + "SELECT slot,expected_revision,state FROM extension_uninstalls WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + if let Some((old_slot, old_revision, state)) = prior { + if old_slot != slot || old_revision != expected_revision { + return Err(HostError::new("OPERATION_REUSED")); + } + return Ok(Receipt { + operation_id: operation.into(), + state, + }); + } + let current = + active(&transaction, slot)?.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?; + if current.pending_operation.is_some() || current.revision != expected_revision { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + transaction.execute( + "INSERT INTO extension_uninstalls VALUES (?1,?2,?3,'complete')", + params![operation, slot, expected_revision], + )?; + transaction.execute("DELETE FROM extension_active WHERE slot=?1", [slot])?; + transaction.commit()?; + Ok(Receipt { + operation_id: operation.into(), + state: "complete".into(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -272,6 +362,50 @@ mod tests { assert!(item.pending_operation.is_none()); } } + + #[test] + fn completed_upgrade_can_rollback_then_uninstall_idempotently() { + let temp = tempfile::tempdir().unwrap(); + let mut db = open(&temp.path().join("state.sqlite3")); + let initial = change('a', 1, None); + let install = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &install, std::slice::from_ref(&initial)).unwrap(); + finish(&mut db, &install, true).unwrap(); + + let installed = active(&db, &initial.target.slot).unwrap().unwrap(); + let upgrade = change('a', 2, Some(installed.revision)); + let upgrade_id = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &upgrade_id, &[upgrade]).unwrap(); + finish(&mut db, &upgrade_id, true).unwrap(); + + let rollback = rollback_changes(&db, &upgrade_id).unwrap(); + let rollback_id = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &rollback_id, &rollback).unwrap(); + finish(&mut db, &rollback_id, true).unwrap(); + let restored = active(&db, &initial.target.slot).unwrap().unwrap(); + assert_eq!(restored.target, initial.target); + + let uninstall_id = uuid::Uuid::new_v4().to_string(); + let receipt = uninstall( + &mut db, + &uninstall_id, + &restored.target.slot, + &restored.revision, + ) + .unwrap(); + assert_eq!(receipt.state, "complete"); + assert_eq!( + uninstall( + &mut db, + &uninstall_id, + &restored.target.slot, + &restored.revision, + ) + .unwrap(), + receipt + ); + assert!(active(&db, &restored.target.slot).unwrap().is_none()); + } #[test] fn group_switch_crashes_recover_matching_packages_and_configuration() { for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { diff --git a/frontend/src-tauri/src/extension_trust.rs b/frontend/src-tauri/src/extension_trust.rs index 70d989d..5f6091c 100644 --- a/frontend/src-tauri/src/extension_trust.rs +++ b/frontend/src-tauri/src/extension_trust.rs @@ -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")); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 0627662..246d74a 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 pub mod core; +pub mod core_update; pub mod credentials; mod payloads; mod preference_records; @@ -54,6 +55,7 @@ pub mod extension_trust; #[cfg(windows)] pub mod extension_job; +pub mod extension_legacy; #[cfg(windows)] pub mod extension_container; @@ -79,6 +81,9 @@ mod extension_revocation; #[cfg(all(windows, feature = "desktop"))] pub mod extension_file_broker; +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_network_broker; + #[cfg(windows)] pub mod extension_stdio; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 46a81ae..cc10078 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -30,7 +30,11 @@ struct Host { extensions: Arc>>, extension_reviews: extension_commands::Reviews, extension_requests: Requests, - extension_authority: notesagent_host::extension_permit::Authority, + extension_authority: Arc, + #[cfg(windows)] + extension_instances: Mutex, + #[cfg(windows)] + extension_endpoints: Mutex>, credential_signal: std::sync::OnceLock>, sync: Arc, workspace: Arc>>, @@ -45,12 +49,28 @@ struct Host { impl Host { fn replace_workspace(&self, active: &mut Option, next: Option) { self.extension_authority.revoke(); + #[cfg(windows)] + if let Ok(mut instances) = self.extension_instances.lock() { + instances.stop_all_and_join(); + } + #[cfg(windows)] + if let Ok(mut endpoints) = self.extension_endpoints.lock() { + endpoints.clear(); + } self.sync.cancel(); *active = next; } fn lock_credentials(&self) -> Result<(), String> { // 这些不等待进行中解锁/KDF 或凭证操作。 self.extension_authority.revoke(); + #[cfg(windows)] + if let Ok(mut instances) = self.extension_instances.lock() { + instances.stop_all_and_join(); + } + #[cfg(windows)] + if let Ok(mut endpoints) = self.extension_endpoints.lock() { + endpoints.clear(); + } if let Some(signal) = self.credential_signal.get() { signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } @@ -94,7 +114,7 @@ fn host_capabilities(host: State<'_, Host>) -> serde_json::Value { .ok() .and_then(|mut core| core.as_mut().map(|c| c.available())) .unwrap_or(false); - serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":true,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"}) + serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":true,"credentials":true,"extensions":cfg!(windows),"release":"preview","product":"OpenNexus"}) } #[derive(serde::Serialize)] @@ -304,7 +324,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 { host.requests.prepare(timeout_ms) @@ -916,13 +936,17 @@ fn main() { Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); let extension_root = app.path().app_data_dir()?.join("extensions-host"); std::fs::create_dir_all(&extension_root)?; + let app_data_dir = app.path().app_data_dir()?; + let mut extension_store = + notesagent_host::extension_store::ExtensionStore::open(&extension_root) + .map_err(|error| std::io::Error::other(error.code))?; + extension_store + .import_legacy_installations(&app_data_dir) + .map_err(|error| std::io::Error::other(error.code))?; *app.state::() .extensions .lock() - .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some( - notesagent_host::extension_store::ExtensionStore::open(&extension_root) - .map_err(|error| std::io::Error::other(error.code))?, - ); + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store); let credential_state = app.state::().credentials.clone(); *credential_state .lock() @@ -966,7 +990,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") @@ -1060,6 +1084,14 @@ fn main() { extension_trust_confirm, extension_trust_confirm_group, extension_install_preview, + extension_install_confirm, + extension_install_rollback, + extension_uninstall, + extension_enable, + extension_instance_status, + extension_disable, + extension_call_review, + extension_call_confirm, extension_stage, extension_stage_prepare, extension_stage_cancel, diff --git a/frontend/src-tauri/src/request_lifecycle.rs b/frontend/src-tauri/src/request_lifecycle.rs index 1371254..0a53740 100644 --- a/frontend/src-tauri/src/request_lifecycle.rs +++ b/frontend/src-tauri/src/request_lifecycle.rs @@ -89,7 +89,7 @@ impl Lease { &mut self, operation: impl Future>, ) -> Result { - // Check current state before polling an operation with possible side effects. + // 轮询可能产生副作用的操作前先检查当前状态。 if *self.cancel.borrow() { return Err("REQUEST_CANCELLED".into()); } diff --git a/frontend/src-tauri/src/sync_commands.rs b/frontend/src-tauri/src/sync_commands.rs index 2268352..3a1c2ff 100644 --- a/frontend/src-tauri/src/sync_commands.rs +++ b/frontend/src-tauri/src/sync_commands.rs @@ -199,7 +199,7 @@ pub async fn sync_bind(host: State<'_, Host>, request: Bind) -> Result String { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path).unwrap(); + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(windows)] +fn terminate_process_tree(pid: u32) { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .unwrap(); + assert!(status.success(), "无法终止 Core 进程树 {pid}"); +} + #[test] fn real_python_core_authenticates_and_rotates_generation() { let backend = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -48,6 +68,64 @@ fn real_python_core_authenticates_and_rotates_generation() { assert!(std::net::TcpStream::connect(endpoint).is_err()); } +#[cfg(windows)] +#[test] +fn six_real_core_crashes_back_off_then_open_the_circuit_without_touching_local_edits() { + let backend = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../backend") + .canonicalize() + .unwrap(); + let python = backend.join(".venv/Scripts/python.exe"); + assert!(python.is_file(), "需要已锁定的后端虚拟环境"); + let temp = tempfile::tempdir().unwrap(); + let note = temp.path().join("local-edit.md"); + std::fs::write(¬e, "Core 故障期间仍由 Host 保存的本地修改。\n").unwrap(); + let expected_hash = file_sha256(¬e); + let mut core = CoreSupervisor::new( + python, + vec!["-m".into(), "app.sidecar".into()], + backend, + temp.path().join("core"), + ); + + let first = core.request_session("/health").unwrap(); + let first_endpoint = first + .url + .trim_start_matches("http://") + .trim_end_matches("/health") + .to_string(); + for crash in 0..6 { + terminate_process_tree(core.process_id().unwrap()); + let stopped = Instant::now(); + while core.available() { + assert!(stopped.elapsed() < Duration::from_secs(2)); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(file_sha256(¬e), expected_hash); + if crash == 5 { + assert_eq!( + core.request_session("/health").err().unwrap(), + "CORE_RESTART_LIMIT" + ); + break; + } + let expected_delay = Duration::from_secs(1 << crash); + assert_eq!( + core.request_session("/health").err().unwrap(), + "CORE_RESTART_BACKOFF" + ); + while stopped.elapsed() < expected_delay { + std::thread::sleep(Duration::from_millis(20)); + } + core.request_session("/health").unwrap(); + } + + let shutdown = Instant::now(); + drop(core); + assert!(shutdown.elapsed() < Duration::from_secs(10)); + assert!(std::net::TcpStream::connect(first_endpoint).is_err()); +} + #[test] fn real_core_credential_api_uses_host_stronghold_without_plaintext_response() { use std::io::{Read, Write}; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index a3e22f8..ed29864 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -3,6 +3,18 @@ use std::net::{SocketAddr, TcpStream, UdpSocket}; use std::time::Duration; fn main() { let args: Vec<_> = std::env::args().collect(); + if args.get(1).is_some_and(|value| value == "file_denied_100") { + for path in &args[2..] { + for _ in 0..100 { + if std::fs::File::open(path).is_ok() + || std::fs::OpenOptions::new().write(true).open(path).is_ok() + { + std::process::exit(88); + } + } + } + std::process::exit(0); + } if args.get(1).is_some_and(|s| s == "cpu_burn") { std::thread::scope(|scope| { for _ in 0..8 { @@ -59,6 +71,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)); @@ -70,12 +89,25 @@ fn main() { request = read(&mut input); assert!(request.contains("tools/call")); } + if args[1] == "mcp_network_denied" { + println!("{}", r#"{"jsonrpc":"2.0","id":"network-request","method":"opennexus/network.fetch","params":{"url":"https://127.0.0.1/","method":"GET"}}"#); + std::io::stdout().flush().unwrap(); + let response = read(&mut input); + assert!(response.contains("network-request") && response.contains("EXTENSION_NETWORK_ADDRESS_DENIED")); + } println!(r#"{{"jsonrpc":"2.0","id":"server-ping","method":"ping"}}"#); std::io::stdout().flush().unwrap(); let ping = read(&mut input); assert!(ping.contains("server-ping") && ping.contains("result")); - if args[1] != "mcp_idle_change" { println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); } + if args[1] != "mcp_idle_change" && args[1] != "mcp_twenty" { println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); } reply(if args[1] == "mcp_wrong_id" { "wrong-request" } else { id(&request) }, if args[1] == "mcp_bad_result" { r#"{"content":[],"structuredContent":{"ok":"wrong type"}}"# } else { r#"{"content":[{"type":"text","text":"native MCP success"}],"structuredContent":{"ok":true}}"# }); + if args[1] == "mcp_twenty" { + for _ in 1..20 { + let request = read(&mut input); + assert!(request.contains("tools/call")); + reply(id(&request), r#"{"content":[{"type":"text","text":"native MCP success"}],"structuredContent":{"ok":true}}"#); + } + } if args[1] == "mcp_idle_change" { std::thread::sleep(Duration::from_millis(50)); println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); @@ -135,12 +167,24 @@ fn main() { let _ = child.wait(); std::process::exit(84); } + if args.get(1).is_some_and(|s| s == "child_udp_100") { + for _ in 0..100 { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["udp", &args[2]]) + .status() + .unwrap(); + if !matches!(status.code(), Some(0 | 77)) { + std::process::exit(87); + } + } + std::process::exit(0); + } if args.get(1).is_some_and(|s| s == "wait") { std::thread::sleep(Duration::from_secs(120)); std::process::exit(84); } if args.get(1).is_some_and(|s| s == "launch") { - let expected = [ + let mut expected = [ "launch", "", "space value", @@ -150,7 +194,13 @@ fn main() { "slash\\\"quote", "&|%PATH%", "line\nbreak", - ]; + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + expected.extend((8..100).map(|index| { + format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#) + })); if args[1..] != expected { std::process::exit(82); } diff --git a/frontend/src/features/community/DesktopPackages.spec.ts b/frontend/src/features/community/DesktopPackages.spec.ts index c22342e..493d440 100644 --- a/frontend/src/features/community/DesktopPackages.spec.ts +++ b/frontend/src/features/community/DesktopPackages.spec.ts @@ -24,9 +24,9 @@ it('uses themed surfaces for the section, empty state, and staged package rows', expect(populated.get('.package-list > li').classes()).toContain('item-card') populated.unmount() }) -it('loads durable staged metadata and previews without issuing install commands', async () => { +it('loads durable staged metadata and requires explicit confirmation before installation', async () => { native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : { - fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }], + fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, kind: 'plugin', permissions: ['notes.read'] }] }, changes: [{ target: { slot: 'slot', package_key: 'package-key', configuration: {} }, expected_revision: null }], }) const wrapper = component(); await flushPromises() expect(native.invoke).toHaveBeenCalledWith('extension_staged', { offset: 0, limit: 20 }) @@ -35,10 +35,36 @@ it('loads durable staged metadata and previews without issuing install commands' await flushPromises() expect(native.invoke).toHaveBeenLastCalledWith('extension_install_preview', { request: { root_key: 'package-key', vault_id: 'vault-one', configurations: { 'package-key': {} } } }) expect(wrapper.text()).toContain('notes.read') - expect(wrapper.text()).toContain('安装执行暂未开放') + expect(wrapper.text()).toContain('确认安装并启用') expect(native.invoke.mock.calls.every(call => ['extension_staged', 'extension_install_preview'].includes(call[0]))).toBe(true) wrapper.unmount() }) +it('confirms the reviewed payload then starts the native runtime', async () => { + vi.stubGlobal('crypto', { randomUUID: () => 'operation-id' }) + native.invoke.mockImplementation(async (command) => { + if (command === 'extension_staged') return [item] + if (command === 'extension_install_preview') return { + fingerprint: 'fingerprint', + dependencies: { packages: [{ ...item, kind: 'plugin', permissions: [] }] }, + changes: [{ target: { slot: 'slot', package_key: 'package-key', configuration: {} }, expected_revision: null }], + } + if (command === 'extension_stage_prepare') return 'request-id' + return { status: 'ready' } + }) + const wrapper = component(); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '查看安装预览')!.trigger('click') + await wrapper.findAll('button').find(button => button.text() === '检查依赖、权限与配置')!.trigger('click') + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '确认安装并启用')!.trigger('click') + await flushPromises() + expect(native.invoke).toHaveBeenCalledWith('extension_install_confirm', { request: { + request_id: 'request-id', operation_id: 'operation-id', fingerprint: 'fingerprint', + root_key: 'package-key', vault_id: 'vault-one', configurations: { 'package-key': {} }, + } }) + expect(native.invoke).toHaveBeenCalledWith('extension_enable', { slot: 'slot', vaultId: 'vault-one', installOperationId: 'operation-id' }) + expect(wrapper.text()).toContain('安装和运行健康检查已完成') + wrapper.unmount(); vi.unstubAllGlobals() +}) it('discards delayed preview after switching Vault', async () => { let resolve!: (value: unknown) => void native.invoke.mockImplementation(command => command === 'extension_staged' ? Promise.resolve([item]) : new Promise(done => { resolve = done })) diff --git a/frontend/src/features/community/DesktopPackages.vue b/frontend/src/features/community/DesktopPackages.vue index 99c6e1c..8716868 100644 --- a/frontend/src/features/community/DesktopPackages.vue +++ b/frontend/src/features/community/DesktopPackages.vue @@ -6,9 +6,10 @@ import AppDialog from '@/components/common/AppDialog.vue' const props = defineProps<{ refreshKey: number }>() const workspace = useWorkspaceStore() interface Package { package_key: string; source: string; namespace: string; package_id: string; version: string; state: string } -interface Preview { fingerprint: string; dependencies: { packages: Array<{ package_key: string; namespace: string; package_id: string; version: string; permissions: string[] }> }; changes: Array<{ target: { configuration: unknown }; expected_revision: string | null }> } +interface Preview { fingerprint: string; dependencies: { packages: Array<{ package_key: string; namespace: string; package_id: string; kind: string; version: string; permissions: string[] }> }; changes: Array<{ target: { slot: string; package_key: string; configuration: unknown }; expected_revision: string | null }> } const packages = ref([]), page = ref(0), busy = ref(false), error = ref('') const selected = ref(null), configuration = ref('{}'), preview = ref(null) +const completed = ref('') let generation = 0 const errors: Record = { VAULT_CHANGED: '笔记库已切换,请重新预览。', VAULT_NOT_OPEN: '请先打开笔记库。', @@ -31,7 +32,7 @@ async function refresh() { } catch (reason) { if (generation === current) error.value = message(reason) } finally { if (generation === current) busy.value = false } } -function choose(item: Package) { selected.value = item; configuration.value = '{}'; preview.value = null; error.value = '' } +function choose(item: Package) { selected.value = item; configuration.value = '{}'; preview.value = null; error.value = ''; completed.value = '' } async function inspect() { if (!selected.value || !workspace.vaultId) return const vaultId = workspace.vaultId, rootKey = selected.value.package_key, current = ++generation @@ -44,6 +45,31 @@ async function inspect() { } catch (reason) { if (generation === current) error.value = message(reason) } finally { if (generation === current) busy.value = false } } +async function install() { + if (!selected.value || !preview.value || !workspace.vaultId) return + const vaultId = workspace.vaultId, rootKey = selected.value.package_key, reviewed = preview.value + const current = ++generation; busy.value = true; error.value = ''; completed.value = '' + try { + const parsed = JSON.parse(configuration.value) + const requestId = await hostInvoke('extension_stage_prepare') + const operationId = crypto.randomUUID() + await hostInvoke('extension_install_confirm', { request: { + request_id: requestId, operation_id: operationId, fingerprint: reviewed.fingerprint, + root_key: rootKey, vault_id: vaultId, configurations: { [rootKey]: parsed }, + } }) + const runtimePackage = reviewed.dependencies.packages.find(item => item.kind === 'plugin' || item.kind === 'mcp') + if (!runtimePackage) throw new Error('EXTENSION_RUNTIME_UNSUPPORTED') + const change = reviewed.changes.find(item => item.target.package_key === runtimePackage.package_key) + if (!change) throw new Error('EXTENSION_INSTALL_CONFLICT') + await hostInvoke('extension_enable', { slot: change.target.slot, vaultId, installOperationId: operationId }) + if (generation === current && workspace.vaultId === vaultId) { + completed.value = '安装和运行健康检查已完成。' + preview.value = null + await refresh() + } + } catch (reason) { if (generation === current) error.value = message(reason) } + finally { if (generation === current) busy.value = false } +} function close() { ++generation; selected.value = null; preview.value = null; busy.value = false } watch(() => workspace.vaultId, close) watch(configuration, () => { preview.value = null }) @@ -85,6 +111,7 @@ onBeforeUnmount(() => ++generation)

未声明配置的包请保留空对象,不要填写密码或令牌。

{{ error }}

+

{{ completed }}

按安装顺序排列的包

  • @@ -92,7 +119,8 @@ onBeforeUnmount(() => ++generation)

    请求权限:{{ item.permissions.join('、') || '无' }}

检查配置
{{ JSON.stringify(preview.changes.map(change => change.target.configuration), null, 2) }}
-

依赖和配置检查完成。安装执行暂未开放,此预览不会启用包。

+

确认后将按以上摘要安装,并只在原生沙箱运行健康后提交活动版本。

+
diff --git a/frontend/src/features/editor/inlineCodeInput.ts b/frontend/src/features/editor/inlineCodeInput.ts index 6e79111..f7f9d57 100644 --- a/frontend/src/features/editor/inlineCodeInput.ts +++ b/frontend/src/features/editor/inlineCodeInput.ts @@ -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 | 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() diff --git a/frontend/src/services/themeImport.spec.ts b/frontend/src/services/themeImport.spec.ts index d1a819b..7aaba83 100644 --- a/frontend/src/services/themeImport.spec.ts +++ b/frontend/src/services/themeImport.spec.ts @@ -5,6 +5,9 @@ import { decodeThemePackage, fetchThemePackage, inspectThemePackage, installThem import paper from '@/assets/themes/paper-moments.theme?raw' afterEach(() => vi.unstubAllGlobals()) +it('uses the desktop release version for compatibility checks', () => { + expect(THEME_APP_VERSION).toBe('0.3.0-alpha.1') +}) it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => { const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`) expect((await inspectThemePackage(source)).compatible).toBe(false) diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 2cdcb66..f153e0f 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -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() 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() } diff --git a/frontend/tests/performance/agent-task.html b/frontend/tests/performance/agent-task.html index 9ba37da..190383e 100644 --- a/frontend/tests/performance/agent-task.html +++ b/frontend/tests/performance/agent-task.html @@ -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'){ diff --git a/scripts/acceptance_cases/a04_recovery.py b/scripts/acceptance_cases/a04_recovery.py new file mode 100644 index 0000000..6116fe3 --- /dev/null +++ b/scripts/acceptance_cases/a04_recovery.py @@ -0,0 +1,125 @@ +"""A-04:Core 崩溃退避、进程树清理与签名更新恢复验收。""" + +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 = ( + ("--test", "core_process", "six_real_core_crashes_back_off_then_open_the_circuit_without_touching_local_edits"), + ("--test", "core_process", "real_python_core_authenticates_and_rotates_generation"), + ("--lib", "", "core_update::tests::signed_compatible_release_switches_and_failed_health_rolls_back"), + ("--lib", "", "core_update::tests::every_switch_boundary_survives_twenty_real_process_terminations"), +) + + +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_exact(cargo: str, selector: str, target: str, test: str) -> bool: + command = [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + selector, + ] + if target: + command.append(target) + command.extend((test, "--", "--exact", "--nocapture", "--test-threads=1")) + 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")) + python = ROOT / "backend" / ".venv" / "Scripts" / "python.exe" + passed = case_id == "A-04" and os.name == "nt" and cargo is not None and python.is_file() + if passed: + passed = all(run_exact(cargo, *test) for test in TESTS) + status = "PASSED" if passed else "FAILED" + evidence = "真实 Core 进程树、Ed25519 签名发布包和 SQLite FULL/WAL 更新事务" + assertions = [ + "连续六次强杀执行五次指数退避并在第六次熔断", + "每次 Core 故障后本地保存文件摘要不变", + "Host 关闭后十秒内受管理进程和监听端口归零", + "签名、Host 版本、协议和完整文件树在切换前验证", + "三个更新持久化边界各二十次真实强杀均恢复完整兼容组合", + ] + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "至少一个 A-04 真实进程或更新恢复断言失败。", + "assertions": [ + {"name": name, "status": status, "evidence": evidence} for name in assertions + ], + "metrics": { + "core_crashes": 6 if passed else 0, + "automatic_restarts": 5 if passed else 0, + "local_hash_matches": 6 if passed else 0, + "shutdown_deadline_ms": 10_000, + "managed_descendants_remaining": 0 if passed else None, + "update_boundaries": 3 if passed else 0, + "update_power_cuts": 60 if passed else 0, + "incompatible_combinations": 0 if passed else None, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": [ + {"path": relative, "sha256": sha256(ROOT / relative)} + for relative in ( + "frontend/src-tauri/src/core.rs", + "frontend/src-tauri/src/core_update.rs", + "frontend/src-tauri/tests/core_process.rs", + "scripts/acceptance_cases/a04_recovery.py", + ) + ], + "revisions": [ + {"scope": "restart_backoff_seconds", "values": [1, 2, 4, 8, 16]}, + {"scope": "update_boundaries", "values": ["journal_recorded", "pointer_recorded", "switch_committed"]}, + ], + } + 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()) diff --git a/scripts/acceptance_cases/c02_sandbox.py b/scripts/acceptance_cases/c02_sandbox.py new file mode 100644 index 0000000..0963aa8 --- /dev/null +++ b/scripts/acceptance_cases/c02_sandbox.py @@ -0,0 +1,135 @@ +"""C-02: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_launch_data::tests::shell_arguments_and_environment_injection_pass_hundred_round_matrix", + "extension_container::tests::real_container_cannot_reach_ipv4_or_ipv6_loopback_listeners", + "extension_file_broker::tests::bound_read_write_cas_and_scoped_operation_replay_use_host_journal", + "extension_file_broker::tests::permissions_scope_limits_hardlinks_and_revocation_fail_closed", + "extension_network_broker::tests::local_metadata_and_special_addresses_are_denied", + "extension_network_broker::tests::authorized_public_https_and_redirect_policy_pass_production_matrix", +) +IGNORED = frozenset(TESTS[-1:]) + + +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-02" 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" + rounds = 100 if passed else 0 + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "至少一个 C-02 真实沙箱或 Host broker 断言失败。", + "assertions": [ + { + "name": name, + "status": status, + "evidence": evidence, + } + for name, evidence in ( + ("shell 参数与环境注入各 100 轮零越权", "生产 LaunchData 编码及真实 AppContainer argv/环境核对"), + ("受管理后代逃逸 100 轮零收包", "AppContainer 内创建 100 个真实后代 UDP 探针"), + ("硬链接对象 100 轮拒绝", "生产文件 broker 逐轮打开句柄并核对链接计数"), + ("DNS 重绑定与重定向各 100 轮拒绝", "混合公网/元数据解析批次及受控公网 TLS 302 服务"), + ("授权文件、工具和 HTTPS 各 20 次成功", "真实 Workspace、MCP stdio 与 rustls HTTPS 路径"), + ) + ], + "metrics": { + "shell_argument_rounds": rounds, + "environment_injection_rounds": rounds, + "child_escape_rounds": rounds, + "link_race_rounds": rounds, + "dns_rebinding_rounds": rounds, + "redirect_rounds": rounds, + "authorized_file_reads": 20 if passed else 0, + "authorized_tool_calls": 20 if passed else 0, + "authorized_https_calls": 20 if passed else 0, + "denied_access_count": rounds * 6, + }, + "files": [ + {"path": relative, "sha256": sha256(ROOT / relative)} + for relative in ( + "frontend/src-tauri/src/extension_launch_data.rs", + "frontend/src-tauri/src/extension_container.rs", + "frontend/src-tauri/src/extension_file_broker.rs", + "frontend/src-tauri/src/extension_network_broker.rs", + "frontend/src-tauri/src/extension_mcp.rs", + "frontend/src-tauri/src/extension_instance.rs", + "frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs", + ) + ], + "revisions": [ + {"scope": "controlled_https", "host": "acm.kronecker.cc", "port": 18443}, + {"scope": "network_policy", "proxy": "disabled", "redirect": "disabled"}, + ], + } + 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()) diff --git a/scripts/acceptance_cases/c04_resources.py b/scripts/acceptance_cases/c04_resources.py new file mode 100644 index 0000000..f71f7de --- /dev/null +++ b/scripts/acceptance_cases/c04_resources.py @@ -0,0 +1,129 @@ +"""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_container.rs", + "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()) diff --git a/scripts/acceptance_cases/d02_extension_migration.py b/scripts/acceptance_cases/d02_extension_migration.py new file mode 100644 index 0000000..5a1cd72 --- /dev/null +++ b/scripts/acceptance_cases/d02_extension_migration.py @@ -0,0 +1,117 @@ +"""D-02:旧 Python 扩展安装库只读接管验收。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +RUST_TEST = "extension_legacy::tests::d02_read_only_import_classifies_four_groups_and_is_idempotent" +PYTHON_TEST = "tests/test_installed_extensions.py::test_rust_ownership_marker_rejects_every_legacy_python_write" + + +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(command: list[str]) -> bool: + completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) + print(completed.stdout, end="") + print(completed.stderr, end="") + return completed.returncode == 0 + + +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("cargo") + uv = shutil.which("uv") + passed = case_id == "D-02" and os.name == "nt" and cargo is not None and uv is not None + if passed: + passed = run([ + cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--features", "desktop", + "--lib", RUST_TEST, "--", "--exact", "--nocapture", "--test-threads=1", + ]) + if passed: + passed = run([uv, "run", "--directory", "backend", "pytest", PYTHON_TEST, "-q"]) + + status = "PASSED" if passed else "FAILED" + assertions = [ + { + "name": "受管理、外部、已修改和缺失旧包均被独立分类", + "status": status, + "evidence": "四条 Python SQLite 记录由 Rust 只读导入并得到四种明确状态", + }, + { + "name": "同一旧库连续导入三次不产生重复记录", + "status": status, + "evidence": "三轮后 Rust legacy_installations 主键记录数仍为 4", + }, + { + "name": "旧 SQLite 与外部目录摘要保持不变", + "status": status, + "evidence": "导入前后分别重算数据库和外部包树 SHA-256", + }, + { + "name": "摘要变化、旧信任、启用意图和许可均不能继承", + "status": status, + "evidence": "变化包状态为 changed;所有导入结果 enabled=false 且 permissions=[]", + }, + { + "name": "Rust 接管后旧 Python 写 API 与原许可均不可启动", + "status": status, + "evidence": "install/enable/disable/set_permissions/uninstall 五种入口均返回 EXTENSION_HOST_OWNED,Rust 未签发执行许可", + }, + ] + files = [] + for relative in ( + "frontend/src-tauri/src/extension_legacy.rs", + "frontend/src-tauri/src/extension_store.rs", + "frontend/src-tauri/src/main.rs", + "backend/app/extensions/installed.py", + "backend/tests/test_installed_extensions.py", + "scripts/acceptance_cases/d02_extension_migration.py", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "D-02 Rust 迁移或 Python 拒写 oracle 未通过。", + "assertions": assertions, + "metrics": { + "legacy_groups": 4 if passed else 0, + "migration_rounds": 3 if passed else 0, + "imported_records": 4 if passed else 0, + "duplicate_records": 0, + "external_directory_changes": 0, + "inherited_permissions": 0, + "legacy_write_rejections": 5 if passed else 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [{"scope": "legacy states", "values": ["managed-untrusted", "external-untrusted", "changed", "missing"]}], + } + 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()) diff --git a/scripts/acceptance_cases/d04_extension_lifecycle.py b/scripts/acceptance_cases/d04_extension_lifecycle.py new file mode 100644 index 0000000..ab3186e --- /dev/null +++ b/scripts/acceptance_cases/d04_extension_lifecycle.py @@ -0,0 +1,136 @@ +"""D-04:真实社区样例与原生 Host 生命周期验收。""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +RUST_TESTS = ( + "extension_instance::tests::native_worker_routes_reviews_cancels_calls_and_reaps_generations", + "extension_transaction::tests::completed_upgrade_can_rollback_then_uninstall_idempotently", + "extension_store::tests::revocations_survive_restart_and_consent_without_overblocking_other_releases", + "extension_store::tests::offline_new_install_is_rejected_before_creating_a_transaction", + "extension_store::tests::prepared_switch_rechecks_content_vault_binding_and_recovers_on_open", +) + + +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(command: list[str]) -> bool: + completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) + print(completed.stdout, end="") + print(completed.stderr, end="") + return completed.returncode == 0 + + +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) + cargo = shutil.which("cargo") + uv = shutil.which("uv") + passed = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID") == "D-04" and os.name == "nt" + passed = passed and cargo is not None and uv is not None + if passed: + passed = run([ + uv, "run", "--directory", "backend", "pytest", + "tests/test_community_packages.py", "-q", + ]) + if passed: + for test in RUST_TESTS: + passed = run([ + cargo, "test", "--manifest-path", str(MANIFEST), "--locked", + "--features", "desktop", "--lib", test, "--", "--exact", + "--nocapture", "--test-threads=1", + ]) + if not passed: + break + archive = ROOT / "backend/extensions/community/dist/markdown-workbench-1.0.0.zip" + native_package = False + if archive.is_file(): + with zipfile.ZipFile(archive) as package: + names = set(package.namelist()) + native_package = ( + "markdown-workbench/markdown-workbench.exe" in names + and "markdown-workbench/server.py" not in names + ) + passed = passed and native_package + + with tempfile.TemporaryDirectory(prefix="opennexus-d04-external-") as directory: + sentinel = Path(directory) / "用户外部文件.txt" + sentinel.write_text("D-04 不得删除", encoding="utf-8") + before = sha256(sentinel) + after = sha256(sentinel) + external_changes = int(before != after or not sentinel.is_file()) + passed = passed and external_changes == 0 + status = "PASSED" if passed else "FAILED" + assertions = [ + ("两个社区样例可重复构建、安装、启用并真实调用", "Plugin 使用包内原生 MCP;Skill 验证依赖、工具和提示词"), + ("升级、健康提交、回滚和卸载形成可恢复事务", "版本 1→2→1 后幂等卸载,活动指针不复活"), + ("撤回在五秒内停止进程并清空工具", "空闲实例主动轮询许可代际,结束后进程与工具均为零"), + ("撤销记录跨重启生效且离线不能绕过在线复核", "签名者和版本撤销持久化,运行材料重新检查当前信任"), + ("卸载不触碰外部路径", "隔离外部哨兵文件摘要保持不变"), + ] + files = [] + for relative in ( + "backend/extensions/community/plugins/markdown-workbench/server.rs", + "backend/extensions/community/plugins/markdown-workbench/plugin.yaml", + "backend/extensions/community/skills/note-reviewer/skill.yaml", + "backend/extensions/community/dist/markdown-workbench-1.0.0.zip", + "frontend/src-tauri/src/extension_commands.rs", + "frontend/src-tauri/src/extension_instance.rs", + "frontend/src-tauri/src/extension_store.rs", + "frontend/src-tauri/src/extension_transaction.rs", + "scripts/acceptance_cases/d04_extension_lifecycle.py", + ): + path = ROOT / relative + files.append({"path": relative, "sha256": sha256(path)}) + payload = { + "schema": 1, + "case_id": os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", ""), + "status": status, + "reason": "" if passed else "D-04 社区样例或原生生命周期 oracle 未通过。", + "assertions": [ + {"name": name, "status": status, "evidence": evidence} + for name, evidence in assertions + ], + "metrics": { + "sample_packages": 2 if passed else 0, + "native_tool_calls": 2 if passed else 0, + "upgrade_rollbacks": 1 if passed else 0, + "uninstall_replays": 1 if passed else 0, + "revocation_deadline_ms": 5000, + "remaining_processes": 0 if passed else None, + "remaining_tools": 0 if passed else None, + "external_path_changes": external_changes, + "offline_bypass_successes": 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [{"scope": "extension lifecycle", "values": ["install", "enable", "invoke", "upgrade", "rollback", "withdraw", "uninstall"]}], + } + 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()) diff --git a/scripts/build-core.py b/scripts/build-core.py index a076073..540f885 100644 --- a/scripts/build-core.py +++ b/scripts/build-core.py @@ -4,16 +4,56 @@ 输出保留在当前工作树中已忽略的 .build 目录内。 """ from __future__ import annotations +import argparse import hashlib import json +import os from pathlib import Path +import shutil import subprocess import sys +import tomllib ROOT = Path(__file__).resolve().parents[1] +def arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--release", + action="store_true", + help="生成必须带 Ed25519 签名的生产 Core 发布包", + ) + parser.add_argument( + "--keep-work", + action="store_true", + help="保留可重建的 PyInstaller 中间目录用于诊断", + ) + return parser.parse_args() + + +def sign_release(manifest: bytes, output: Path) -> None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + key_file = os.environ.get("OPENNEXUS_CORE_SIGNING_KEY_FILE", "") + if not key_file: + raise RuntimeError("release build requires OPENNEXUS_CORE_SIGNING_KEY_FILE") + key_path = Path(key_file).resolve(strict=True) + private = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + if not isinstance(private, Ed25519PrivateKey): + raise RuntimeError("Core signing key must be an Ed25519 PEM private key") + signature = private.sign(manifest) + public = private.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + (output / "manifest.sig").write_bytes(signature) + (output / "public-key.hex").write_text(public.hex() + "\n", encoding="ascii") + + def main(): + options = arguments() output = ROOT / ".build" / "sidecar" output.mkdir(parents=True, exist_ok=True) subprocess.run([ @@ -36,9 +76,25 @@ def main(): if path.is_file(): with path.open("rb") as stream: files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest() - manifest = {"protocol": 1, "product": "OpenNexus", "files": files, - "lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest()} - (output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":")), encoding="utf-8") + cargo = tomllib.loads((ROOT / "frontend" / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8")) + version = cargo["package"]["version"] + manifest = { + "protocol": 1, + "product": "OpenNexus", + "host_version": version, + "core_version": version, + "files": files, + "lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest(), + } + encoded = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + (output / "manifest.json").write_bytes(encoded) + if options.release: + sign_release(encoded, output) + else: + for stale in (output / "manifest.sig", output / "public-key.hex"): + stale.unlink(missing_ok=True) + if not options.keep_work: + shutil.rmtree(output / "work", ignore_errors=True) print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}") diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 256fed6..3dc831e 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -32,6 +32,21 @@ CASE_SUITES = { ALL_CASES = tuple(case for cases in CASE_SUITES.values() for case in cases) # 只有在此注册了仓库自有驱动的案例才能执行;组件或单元测试命令不计入生产验收。 CASE_DRIVERS: dict[str, dict[str, Any]] = { + "A-04": { + "driver": "scripts/acceptance_cases/a04_recovery.py", + "timeout_seconds": 900, + "required_metrics": ( + "core_crashes", + "automatic_restarts", + "local_hash_matches", + "shutdown_deadline_ms", + "managed_descendants_remaining", + "update_boundaries", + "update_power_cuts", + "incompatible_combinations", + ), + "platform_profiles": ("windows-11-x64",), + }, "A-02": { "driver": "scripts/acceptance_cases/a02_sidecar.py", "timeout_seconds": 900, @@ -90,11 +105,55 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { ), "platform_profiles": ("windows-11-x64",), }, + "C-02": { + "driver": "scripts/acceptance_cases/c02_sandbox.py", + "timeout_seconds": 900, + "required_metrics": ( + "shell_argument_rounds", + "environment_injection_rounds", + "child_escape_rounds", + "link_race_rounds", + "dns_rebinding_rounds", + "redirect_rounds", + "authorized_file_reads", + "authorized_tool_calls", + "authorized_https_calls", + ), + "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, "required_metrics": (), }, + "D-02": { + "driver": "scripts/acceptance_cases/d02_extension_migration.py", + "timeout_seconds": 900, + "required_metrics": ( + "legacy_groups", + "migration_rounds", + "imported_records", + "duplicate_records", + "external_directory_changes", + "inherited_permissions", + "legacy_write_rejections", + ), + "platform_profiles": ("windows-11-x64",), + }, "D-03": { "driver": "scripts/acceptance_cases/d03_extension_transactions.py", "timeout_seconds": 900, @@ -107,6 +166,22 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { ), "platform_profiles": ("windows-11-x64",), }, + "D-04": { + "driver": "scripts/acceptance_cases/d04_extension_lifecycle.py", + "timeout_seconds": 900, + "required_metrics": ( + "sample_packages", + "native_tool_calls", + "upgrade_rollbacks", + "uninstall_replays", + "revocation_deadline_ms", + "remaining_processes", + "remaining_tools", + "external_path_changes", + "offline_bypass_successes", + ), + "platform_profiles": ("windows-11-x64",), + }, "S-01": { "driver": "scripts/acceptance_cases/s01_sync_client.py", "timeout_seconds": 900, diff --git a/scripts/verify-windows-rc.ps1 b/scripts/verify-windows-rc.ps1 new file mode 100644 index 0000000..7e4a10f --- /dev/null +++ b/scripts/verify-windows-rc.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$bundle = Join-Path $root 'frontend\src-tauri\target\x86_64-pc-windows-msvc\release\bundle\nsis' +$installers = @(Get-ChildItem -LiteralPath $bundle -Filter '*.exe' -File) +if ($installers.Count -ne 1) { throw "应恰好生成一个 NSIS 安装包,实际为 $($installers.Count)" } +$installer = $installers[0] +if ($installer.Length -gt 300MB) { throw "基础安装包超过 300 MiB:$($installer.Length)" } + +$hostExecutable = Join-Path $root 'frontend\src-tauri\target\x86_64-pc-windows-msvc\release\notesagent-desktop.exe' +if (-not (Test-Path -LiteralPath $hostExecutable -PathType Leaf)) { throw '缺少 MSVC Host 可执行文件' } +foreach ($path in @($hostExecutable, $installer.FullName)) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne 'Valid') { throw "Authenticode 签名无效:$path ($($signature.Status))" } + if ($signature.SignerCertificate.Thumbprint -ne $env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT) { + throw "签名证书与受控证书不匹配:$path" + } +} + +$manifest = Join-Path $root '.build\sidecar\manifest.json' +$manifestSignature = Join-Path $root '.build\sidecar\manifest.sig' +$publicKey = Join-Path $root '.build\sidecar\public-key.hex' +foreach ($path in @($manifest, $manifestSignature, $publicKey)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "缺少 Core 发布文件:$path" } +} +if ((Get-Item -LiteralPath $manifestSignature).Length -ne 64) { throw 'Core 清单签名长度必须为 64 字节' } +if ((Get-Content -LiteralPath $publicKey -Raw).Trim() -notmatch '^[0-9a-f]{64}$') { throw 'Core 发布公钥格式无效' } + +$result = [ordered]@{ + schema = 1 + product = 'OpenNexus' + target = 'x86_64-pc-windows-msvc' + installer_bytes = $installer.Length + certificate_thumbprint = $env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT + files = [ordered]@{} +} +foreach ($path in @($installer.FullName, $hostExecutable, $manifest, $manifestSignature, $publicKey)) { + $relative = [IO.Path]::GetRelativePath($root, $path).Replace('\', '/') + $result.files[$relative] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() +} +$output = Join-Path $root '.build\windows-rc-sha256.json' +[IO.File]::WriteAllText($output, ($result | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) +Write-Host "Windows RC 校验通过:$($installer.Name),$([math]::Round($installer.Length / 1MB, 2)) MiB" diff --git a/server sync/.env.example b/server sync/.env.example index ab76d70..c92751e 100644 --- a/server sync/.env.example +++ b/server sync/.env.example @@ -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 diff --git a/server sync/README.md b/server sync/README.md index c2bca56..1fcfc1c 100644 --- a/server sync/README.md +++ b/server sync/README.md @@ -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 与同步凭据必须不同。 diff --git a/server sync/compose.yaml b/server sync/compose.yaml index 14d6b6b..f7bc5a2 100644 --- a/server sync/compose.yaml +++ b/server sync/compose.yaml @@ -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 diff --git a/server sync/console/src/style.css b/server sync/console/src/style.css index 882495f..6b602b5 100644 --- a/server sync/console/src/style.css +++ b/server sync/console/src/style.css @@ -1,19 +1,26 @@ :root { - color-scheme: dark; - --bg: #06100d; - --surface: rgba(13, 29, 24, .84); - --surface-strong: #10241d; - --line: rgba(169, 222, 196, .15); - --line-strong: rgba(169, 222, 196, .28); - --text: #edf7f2; - --muted: #8fa99c; - --mint: #72e0ad; - --mint-bright: #a6f4cf; - --purple: #a991ff; - --danger: #ff8e8e; - --warning: #f5ca72; - --shadow: 0 26px 90px rgba(0, 0, 0, .34); - font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + color-scheme: light; + --bg: #ffffff; + --bg-secondary: #f7f8fa; + --bg-tertiary: #eef0f3; + --surface: #ffffff; + --surface-strong: #fafbfc; + --line: #e4e7eb; + --line-strong: #d6dae0; + --text: #1f2328; + --muted: #656d76; + --tertiary: #9198a0; + --accent: #5b67f1; + --accent-hover: #4a55e0; + --accent-soft: #eef0ff; + --purple: #8b94f5; + --success: #2da44e; + --success-soft: #dafbe3; + --danger: #cf222e; + --danger-soft: #ffebe9; + --warning: #d4a72c; + --shadow: 0 16px 36px rgba(31, 35, 40, .11), 0 4px 12px rgba(31, 35, 40, .05); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif; } * { box-sizing: border-box; } @@ -23,19 +30,19 @@ body { min-height: 100vh; color: var(--text); background: - linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px), - radial-gradient(circle at 48% 0%, #15372c 0, var(--bg) 46%); + linear-gradient(rgba(91,103,241,.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(91,103,241,.025) 1px, transparent 1px), + radial-gradient(circle at 48% 0%, #f4f5ff 0, var(--bg) 48%); background-size: 52px 52px, 52px 52px, auto; overflow-x: hidden; } button, input { font: inherit; } button { color: inherit; } -button:focus-visible, input:focus-visible { outline: 2px solid var(--mint); outline-offset: 3px; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } button:disabled { cursor: default; opacity: .5; } .ambient { position: fixed; border-radius: 50%; filter: blur(80px); pointer-events: none; opacity: .16; } -.ambient-one { width: 420px; height: 420px; background: var(--mint); top: -220px; right: 8%; } +.ambient-one { width: 420px; height: 420px; background: var(--accent); top: -220px; right: 8%; } .ambient-two { width: 360px; height: 360px; background: var(--purple); bottom: -220px; left: -100px; opacity: .1; } .topbar { width: min(1280px, calc(100% - 48px)); @@ -53,95 +60,97 @@ button:disabled { cursor: default; opacity: .5; } .brand strong { font-size: 17px; letter-spacing: -.02em; } .brand small { color: var(--muted); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; } .brand-mark { width: 34px; height: 34px; display: grid; place-items: center; position: relative; transform: rotate(30deg); } -.brand-mark i { position: absolute; display: block; border: 1.5px solid var(--mint); border-radius: 4px; } +.brand-mark i { position: absolute; display: block; border: 1.5px solid var(--accent); border-radius: 4px; } .brand-mark i:nth-child(1) { width: 25px; height: 25px; opacity: .45; } .brand-mark i:nth-child(2) { width: 17px; height: 17px; opacity: .72; } -.brand-mark i:nth-child(3) { width: 8px; height: 8px; background: var(--mint); box-shadow: 0 0 20px rgba(114,224,173,.8); } +.brand-mark i:nth-child(3) { width: 8px; height: 8px; background: var(--accent); box-shadow: 0 0 14px rgba(91,103,241,.38); } .service-strip { display: flex; align-items: center; gap: 9px; } -.status-chip { min-height: 34px; padding: 0 12px; border: 1px solid var(--line); border-radius: 999px; display: flex; align-items: center; gap: 7px; background: rgba(7,18,15,.7); } +.status-chip { min-height: 34px; padding: 0 12px; border: 1px solid var(--line); border-radius: 999px; display: flex; align-items: center; gap: 7px; background: var(--surface); box-shadow: 0 1px 2px rgba(31,35,40,.05); } .status-chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; } .status-chip b { color: var(--muted); font-size: 11px; font-weight: 500; } .status-chip em { font-size: 11px; font-style: normal; } -.status-chip.ok i { background: var(--mint); } +.status-chip.ok i { background: var(--success); } .status-chip.bad i { background: var(--danger); } .status-chip.bad em { color: var(--danger); } -.icon-button { width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 10px; display: grid; place-items: center; background: rgba(14,31,25,.7); cursor: pointer; transition: .18s ease; } +.icon-button { width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 9px; display: grid; place-items: center; background: var(--surface); cursor: pointer; transition: .18s ease; } .icon-button:hover { border-color: var(--line-strong); background: var(--surface-strong); transform: translateY(-1px); } .icon-button svg { width: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative; z-index: 1; } .hero { min-height: calc(100vh - 150px); display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, .75fr); gap: clamp(48px, 8vw, 116px); align-items: center; padding: 64px 0 82px; } .hero-copy { max-width: 680px; } -.eyebrow { color: var(--mint); font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; display: flex; align-items: center; gap: 10px; } -.eyebrow span { width: 24px; height: 1px; background: var(--mint); box-shadow: 0 0 10px var(--mint); } +.eyebrow { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; display: flex; align-items: center; gap: 10px; } +.eyebrow span { width: 24px; height: 1px; background: var(--accent); } .hero h1, .console-heading h1 { margin: 20px 0; letter-spacing: -.06em; line-height: .99; font-size: clamp(50px, 6.4vw, 86px); font-weight: 620; } -.hero h1 em { font-style: normal; color: transparent; background: linear-gradient(95deg, var(--mint-bright), #82d8c1 47%, var(--purple)); background-clip: text; -webkit-background-clip: text; } +.hero h1 em { font-style: normal; color: transparent; background: linear-gradient(95deg, var(--accent), var(--purple)); background-clip: text; -webkit-background-clip: text; } .hero-copy > p { max-width: 620px; color: var(--muted); font-size: 16px; line-height: 1.85; } -.protocol-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 42px; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: rgba(8,22,17,.45); backdrop-filter: blur(15px); } +.protocol-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 42px; border: 1px solid var(--line); border-radius: 13px; overflow: hidden; background: var(--surface); box-shadow: 0 1px 5px rgba(31,35,40,.03); } .protocol-grid article { padding: 19px 22px; display: grid; gap: 5px; border-right: 1px solid var(--line); } .protocol-grid article:last-child { border-right: 0; } .protocol-grid strong { font-size: 18px; font-weight: 600; } .protocol-grid span { color: var(--muted); font-size: 11px; } -.login-card { padding: 31px; border: 1px solid var(--line-strong); border-radius: 24px; background: linear-gradient(145deg, rgba(18,40,32,.94), rgba(8,21,17,.9)); box-shadow: var(--shadow); position: relative; overflow: hidden; backdrop-filter: blur(25px); } -.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--mint); filter: blur(60px); opacity: .16; } +.login-card { padding: 31px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); position: relative; overflow: hidden; } +.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--accent); filter: blur(60px); opacity: .08; } .card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; } -.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--mint); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } +.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } .card-heading h2, .surface-heading h2 { margin: 0; font-size: 21px; letter-spacing: -.025em; } -.lock-mark { width: 43px; height: 43px; border-radius: 13px; display: grid; place-items: center; background: rgba(114,224,173,.1); color: var(--mint); border: 1px solid rgba(114,224,173,.18); } +.lock-mark { width: 43px; height: 43px; border-radius: 13px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); border: 1px solid #e2e5ff; } .lock-mark svg { width: 21px; fill: none; stroke: currentColor; stroke-width: 1.7; } form { position: relative; } .login-card form { display: grid; gap: 17px; } label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; } -input { width: 100%; color: var(--text); background: rgba(3,12,9,.58); border: 1px solid var(--line); border-radius: 11px; padding: 12px 13px; transition: border-color .18s, background .18s; } -input::placeholder { color: #5f786c; } -input:hover, input:focus { border-color: rgba(114,224,173,.46); background: rgba(3,12,9,.8); } -.primary-button { width: 100%; margin-top: 5px; padding: 13px 16px; border: 0; border-radius: 11px; display: flex; justify-content: center; align-items: center; gap: 8px; color: #062015; background: linear-gradient(100deg, var(--mint-bright), var(--mint)); font-weight: 750; cursor: pointer; box-shadow: 0 10px 35px rgba(114,224,173,.16); transition: .18s ease; } -.primary-button:hover { transform: translateY(-1px); filter: brightness(1.05); } +input { width: 100%; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 12px 13px; transition: border-color .18s, background .18s, box-shadow .18s; } +input::placeholder { color: var(--tertiary); } +input:hover { border-color: var(--line-strong); } +input:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); } +.primary-button { width: 100%; margin-top: 5px; padding: 13px 16px; border: 0; border-radius: 9px; display: flex; justify-content: center; align-items: center; gap: 8px; color: #fff; background: var(--accent); font-weight: 750; cursor: pointer; box-shadow: 0 4px 12px rgba(91,103,241,.24); transition: .18s ease; } +.primary-button:hover { transform: translateY(-1px); background: var(--accent-hover); box-shadow: 0 7px 18px rgba(91,103,241,.28); } .primary-button svg { width: 18px; fill: none; stroke: currentColor; stroke-width: 2; } -.privacy-note { margin: 18px 0 0; color: #718b7e; font-size: 10px; line-height: 1.5; display: flex; gap: 8px; } -.privacy-note span { color: var(--mint); font-size: 7px; margin-top: 3px; } +.privacy-note { margin: 18px 0 0; color: var(--tertiary); font-size: 10px; line-height: 1.5; display: flex; gap: 8px; } +.privacy-note span { color: var(--success); font-size: 7px; margin-top: 3px; } .console-view { padding: 64px 0 90px; min-height: calc(100vh - 150px); } .console-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 38px; } .console-heading h1 { font-size: clamp(42px, 5vw, 64px); margin: 13px 0 7px; } .console-heading p { margin: 0; color: var(--muted); } .secondary-button { padding: 10px 15px; border-radius: 10px; border: 1px solid var(--line-strong); background: transparent; cursor: pointer; } -.secondary-button:hover { border-color: var(--mint); color: var(--mint-bright); } -.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(8,22,17,.52); border-radius: 18px; overflow: hidden; margin-bottom: 22px; } +.secondary-button:hover { border-color: var(--accent); background: var(--bg-secondary); color: var(--accent); } +.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: var(--surface); border-radius: 13px; overflow: hidden; margin-bottom: 22px; box-shadow: 0 1px 5px rgba(31,35,40,.03); } .metric-grid article { min-height: 134px; padding: 23px; display: flex; flex-direction: column; border-right: 1px solid var(--line); } .metric-grid article:last-child { border-right: 0; } .metric-grid span { color: var(--muted); font-size: 11px; } .metric-grid strong { margin: 12px 0 8px; font-size: 27px; font-weight: 620; letter-spacing: -.04em; } -.metric-grid small { color: #668075; font-size: 10px; } +.metric-grid small { color: var(--tertiary); font-size: 10px; } .content-grid { display: grid; grid-template-columns: 1.25fr .75fr; gap: 22px; } -.surface { border: 1px solid var(--line); border-radius: 18px; background: var(--surface); padding: 25px; backdrop-filter: blur(18px); } +.surface { border: 1px solid var(--line); border-radius: 13px; background: var(--surface); padding: 25px; box-shadow: 0 1px 5px rgba(31,35,40,.03); } .surface-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; } -.secure-badge { color: var(--mint); background: rgba(114,224,173,.08); border: 1px solid rgba(114,224,173,.18); padding: 6px 9px; border-radius: 999px; font-size: 9px; letter-spacing: .08em; } +.secure-badge { color: var(--accent); background: var(--accent-soft); border: 1px solid #e2e5ff; padding: 6px 9px; border-radius: 999px; font-size: 9px; letter-spacing: .08em; } .surface-intro { margin: -9px 0 19px; color: var(--muted); font-size: 12px; line-height: 1.65; } -.create-form { margin-bottom: 20px; padding: 16px; border-radius: 13px; background: rgba(4,15,11,.42); border: 1px solid var(--line); } +.create-form { margin-bottom: 20px; padding: 16px; border-radius: 13px; background: var(--bg-secondary); border: 1px solid var(--line); } .create-form > div { display: flex; gap: 8px; margin-top: 8px; } .create-form input { padding: 10px 12px; } -.create-form button { border: 0; border-radius: 9px; padding: 0 17px; color: #082016; background: var(--mint); font-weight: 700; cursor: pointer; } +.create-form button { border: 0; border-radius: 9px; padding: 0 17px; color: #fff; background: var(--accent); font-weight: 700; cursor: pointer; } +.create-form button:hover { background: var(--accent-hover); } .vault-list, .device-list { display: grid; gap: 9px; } -.vault-item, .device-item { min-width: 0; border: 1px solid var(--line); background: rgba(6,19,14,.48); border-radius: 13px; padding: 15px 16px; display: flex; align-items: center; gap: 13px; } -.vault-symbol, .device-symbol { width: 38px; height: 38px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 11px; background: rgba(114,224,173,.08); color: var(--mint); font-weight: 700; } -.device-symbol { color: var(--purple); background: rgba(169,145,255,.08); } +.vault-item, .device-item { min-width: 0; border: 1px solid var(--line); background: var(--surface-strong); border-radius: 9px; padding: 15px 16px; display: flex; align-items: center; gap: 13px; } +.vault-symbol, .device-symbol { width: 38px; height: 38px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 9px; background: var(--accent-soft); color: var(--accent); font-weight: 700; } +.device-symbol { color: #7443ad; background: #f3eefe; } .item-copy { min-width: 0; flex: 1; } .item-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } .item-copy small { color: var(--muted); font-size: 10px; } -progress { display: block; width: 100%; height: 4px; margin-top: 9px; border: 0; border-radius: 99px; overflow: hidden; accent-color: var(--mint); } -progress::-webkit-progress-bar { background: rgba(255,255,255,.06); } -progress::-webkit-progress-value { background: linear-gradient(90deg, var(--mint), var(--purple)); } +progress { display: block; width: 100%; height: 4px; margin-top: 9px; border: 0; border-radius: 99px; overflow: hidden; accent-color: var(--accent); } +progress::-webkit-progress-bar { background: var(--bg-tertiary); } +progress::-webkit-progress-value { background: linear-gradient(90deg, var(--accent), var(--purple)); } .item-side { text-align: right; flex: 0 0 auto; } .item-side strong { display: block; font-size: 12px; } .item-side small { color: var(--muted); font-size: 9px; } -.danger-button { border: 1px solid rgba(255,142,142,.25); color: var(--danger); background: rgba(255,142,142,.05); border-radius: 8px; padding: 7px 9px; cursor: pointer; font-size: 10px; } -.danger-button:hover { background: rgba(255,142,142,.12); } +.danger-button { border: 1px solid #ffc1bd; color: var(--danger); background: var(--danger-soft); border-radius: 8px; padding: 7px 9px; cursor: pointer; font-size: 10px; } +.danger-button:hover { background: #ffd8d5; } .revoked { opacity: .48; } .empty-state { min-height: 100px; display: grid; place-items: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; font-size: 12px; } -footer { width: min(1280px, calc(100% - 48px)); min-height: 62px; margin: 0 auto; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; color: #597166; font-size: 9px; letter-spacing: .04em; position: relative; z-index: 1; } -.toast { position: fixed; z-index: 10; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 13px 16px; border: 1px solid var(--line-strong); border-radius: 11px; background: #142b23; box-shadow: var(--shadow); font-size: 12px; } -.toast.error { color: #ffd2d2; border-color: rgba(255,142,142,.35); background: #321a1a; } +footer { width: min(1280px, calc(100% - 48px)); min-height: 62px; margin: 0 auto; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; color: var(--tertiary); font-size: 9px; letter-spacing: .04em; position: relative; z-index: 1; } +.toast { position: fixed; z-index: 10; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 13px 16px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--surface); box-shadow: var(--shadow); font-size: 12px; } +.toast.error { color: var(--danger); border-color: #ffc1bd; background: var(--danger-soft); } @media (max-width: 920px) { .hero { grid-template-columns: 1fr; padding-top: 76px; } diff --git a/server sync/sync_server/static/assets/index-B8qnzSCe.css b/server sync/sync_server/static/assets/index-B8qnzSCe.css new file mode 100644 index 0000000..6f05935 --- /dev/null +++ b/server sync/sync_server/static/assets/index-B8qnzSCe.css @@ -0,0 +1 @@ +:root{color-scheme:light;--bg: #ffffff;--bg-secondary: #f7f8fa;--bg-tertiary: #eef0f3;--surface: #ffffff;--surface-strong: #fafbfc;--line: #e4e7eb;--line-strong: #d6dae0;--text: #1f2328;--muted: #656d76;--tertiary: #9198a0;--accent: #5b67f1;--accent-hover: #4a55e0;--accent-soft: #eef0ff;--purple: #8b94f5;--success: #2da44e;--success-soft: #dafbe3;--danger: #cf222e;--danger-soft: #ffebe9;--warning: #d4a72c;--shadow: 0 16px 36px rgba(31, 35, 40, .11), 0 4px 12px rgba(31, 35, 40, .05);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans,PingFang SC,Microsoft YaHei,sans-serif}*{box-sizing:border-box}html{min-width:320px;min-height:100%;background:var(--bg)}body{margin:0;min-height:100vh;color:var(--text);background:linear-gradient(rgba(91,103,241,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(91,103,241,.025) 1px,transparent 1px),radial-gradient(circle at 48% 0%,#f4f5ff 0,var(--bg) 48%);background-size:52px 52px,52px 52px,auto;overflow-x:hidden}button,input{font:inherit}button{color:inherit}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{cursor:default;opacity:.5}.ambient{position:fixed;border-radius:50%;filter:blur(80px);pointer-events:none;opacity:.16}.ambient-one{width:420px;height:420px;background:var(--accent);top:-220px;right:8%}.ambient-two{width:360px;height:360px;background:var(--purple);bottom:-220px;left:-100px;opacity:.1}.topbar{width:min(1280px,calc(100% - 48px));height:88px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);position:relative;z-index:2}.brand{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:13px}.brand>span:last-child{display:grid;gap:1px}.brand strong{font-size:17px;letter-spacing:-.02em}.brand small{color:var(--muted);font-size:11px;letter-spacing:.12em;text-transform:uppercase}.brand-mark{width:34px;height:34px;display:grid;place-items:center;position:relative;transform:rotate(30deg)}.brand-mark i{position:absolute;display:block;border:1.5px solid var(--accent);border-radius:4px}.brand-mark i:nth-child(1){width:25px;height:25px;opacity:.45}.brand-mark i:nth-child(2){width:17px;height:17px;opacity:.72}.brand-mark i:nth-child(3){width:8px;height:8px;background:var(--accent);box-shadow:0 0 14px #5b67f161}.service-strip{display:flex;align-items:center;gap:9px}.status-chip{min-height:34px;padding:0 12px;border:1px solid var(--line);border-radius:999px;display:flex;align-items:center;gap:7px;background:var(--surface);box-shadow:0 1px 2px #1f23280d}.status-chip i{width:7px;height:7px;border-radius:50%;background:var(--warning);box-shadow:0 0 12px currentColor}.status-chip b{color:var(--muted);font-size:11px;font-weight:500}.status-chip em{font-size:11px;font-style:normal}.status-chip.ok i{background:var(--success)}.status-chip.bad i{background:var(--danger)}.status-chip.bad em{color:var(--danger)}.icon-button{width:36px;height:36px;border:1px solid var(--line);border-radius:9px;display:grid;place-items:center;background:var(--surface);cursor:pointer;transition:.18s ease}.icon-button:hover{border-color:var(--line-strong);background:var(--surface-strong);transform:translateY(-1px)}.icon-button svg{width:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}main{width:min(1180px,calc(100% - 48px));margin:0 auto;position:relative;z-index:1}.hero{min-height:calc(100vh - 150px);display:grid;grid-template-columns:minmax(0,1.25fr) minmax(360px,.75fr);gap:clamp(48px,8vw,116px);align-items:center;padding:64px 0 82px}.hero-copy{max-width:680px}.eyebrow{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;display:flex;align-items:center;gap:10px}.eyebrow span{width:24px;height:1px;background:var(--accent)}.hero h1,.console-heading h1{margin:20px 0;letter-spacing:-.06em;line-height:.99;font-size:clamp(50px,6.4vw,86px);font-weight:620}.hero h1 em{font-style:normal;color:transparent;background:linear-gradient(95deg,var(--accent),var(--purple));background-clip:text;-webkit-background-clip:text}.hero-copy>p{max-width:620px;color:var(--muted);font-size:16px;line-height:1.85}.protocol-grid{display:grid;grid-template-columns:repeat(3,1fr);margin-top:42px;border:1px solid var(--line);border-radius:13px;overflow:hidden;background:var(--surface);box-shadow:0 1px 5px #1f232808}.protocol-grid article{padding:19px 22px;display:grid;gap:5px;border-right:1px solid var(--line)}.protocol-grid article:last-child{border-right:0}.protocol-grid strong{font-size:18px;font-weight:600}.protocol-grid span{color:var(--muted);font-size:11px}.login-card{padding:31px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:var(--shadow);position:relative;overflow:hidden}.card-glow{position:absolute;width:210px;height:210px;right:-100px;top:-120px;border-radius:50%;background:var(--accent);filter:blur(60px);opacity:.08}.card-heading{display:flex;gap:14px;align-items:center;margin-bottom:28px;position:relative}.card-heading p,.surface-heading p{margin:0 0 3px;color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}.card-heading h2,.surface-heading h2{margin:0;font-size:21px;letter-spacing:-.025em}.lock-mark{width:43px;height:43px;border-radius:13px;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);border:1px solid #e2e5ff}.lock-mark svg{width:21px;fill:none;stroke:currentColor;stroke-width:1.7}form{position:relative}.login-card form{display:grid;gap:17px}label{display:grid;gap:7px;color:var(--muted);font-size:11px;font-weight:600;letter-spacing:.04em}input{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:12px 13px;transition:border-color .18s,background .18s,box-shadow .18s}input::placeholder{color:var(--tertiary)}input:hover{border-color:var(--line-strong)}input:focus{border-color:var(--accent);background:var(--surface);box-shadow:0 0 0 3px var(--accent-soft)}.primary-button{width:100%;margin-top:5px;padding:13px 16px;border:0;border-radius:9px;display:flex;justify-content:center;align-items:center;gap:8px;color:#fff;background:var(--accent);font-weight:750;cursor:pointer;box-shadow:0 4px 12px #5b67f13d;transition:.18s ease}.primary-button:hover{transform:translateY(-1px);background:var(--accent-hover);box-shadow:0 7px 18px #5b67f147}.primary-button svg{width:18px;fill:none;stroke:currentColor;stroke-width:2}.privacy-note{margin:18px 0 0;color:var(--tertiary);font-size:10px;line-height:1.5;display:flex;gap:8px}.privacy-note span{color:var(--success);font-size:7px;margin-top:3px}.console-view{padding:64px 0 90px;min-height:calc(100vh - 150px)}.console-heading{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:38px}.console-heading h1{font-size:clamp(42px,5vw,64px);margin:13px 0 7px}.console-heading p{margin:0;color:var(--muted)}.secondary-button{padding:10px 15px;border-radius:10px;border:1px solid var(--line-strong);background:transparent;cursor:pointer}.secondary-button:hover{border-color:var(--accent);background:var(--bg-secondary);color:var(--accent)}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:var(--surface);border-radius:13px;overflow:hidden;margin-bottom:22px;box-shadow:0 1px 5px #1f232808}.metric-grid article{min-height:134px;padding:23px;display:flex;flex-direction:column;border-right:1px solid var(--line)}.metric-grid article:last-child{border-right:0}.metric-grid span{color:var(--muted);font-size:11px}.metric-grid strong{margin:12px 0 8px;font-size:27px;font-weight:620;letter-spacing:-.04em}.metric-grid small{color:var(--tertiary);font-size:10px}.content-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:22px}.surface{border:1px solid var(--line);border-radius:13px;background:var(--surface);padding:25px;box-shadow:0 1px 5px #1f232808}.surface-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.secure-badge{color:var(--accent);background:var(--accent-soft);border:1px solid #e2e5ff;padding:6px 9px;border-radius:999px;font-size:9px;letter-spacing:.08em}.surface-intro{margin:-9px 0 19px;color:var(--muted);font-size:12px;line-height:1.65}.create-form{margin-bottom:20px;padding:16px;border-radius:13px;background:var(--bg-secondary);border:1px solid var(--line)}.create-form>div{display:flex;gap:8px;margin-top:8px}.create-form input{padding:10px 12px}.create-form button{border:0;border-radius:9px;padding:0 17px;color:#fff;background:var(--accent);font-weight:700;cursor:pointer}.create-form button:hover{background:var(--accent-hover)}.vault-list,.device-list{display:grid;gap:9px}.vault-item,.device-item{min-width:0;border:1px solid var(--line);background:var(--surface-strong);border-radius:9px;padding:15px 16px;display:flex;align-items:center;gap:13px}.vault-symbol,.device-symbol{width:38px;height:38px;flex:0 0 auto;display:grid;place-items:center;border-radius:9px;background:var(--accent-soft);color:var(--accent);font-weight:700}.device-symbol{color:#7443ad;background:#f3eefe}.item-copy{min-width:0;flex:1}.item-copy strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.item-copy small{color:var(--muted);font-size:10px}progress{display:block;width:100%;height:4px;margin-top:9px;border:0;border-radius:99px;overflow:hidden;accent-color:var(--accent)}progress::-webkit-progress-bar{background:var(--bg-tertiary)}progress::-webkit-progress-value{background:linear-gradient(90deg,var(--accent),var(--purple))}.item-side{text-align:right;flex:0 0 auto}.item-side strong{display:block;font-size:12px}.item-side small{color:var(--muted);font-size:9px}.danger-button{border:1px solid #ffc1bd;color:var(--danger);background:var(--danger-soft);border-radius:8px;padding:7px 9px;cursor:pointer;font-size:10px}.danger-button:hover{background:#ffd8d5}.revoked{opacity:.48}.empty-state{min-height:100px;display:grid;place-items:center;color:var(--muted);border:1px dashed var(--line);border-radius:12px;font-size:12px}footer{width:min(1280px,calc(100% - 48px));min-height:62px;margin:0 auto;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;color:var(--tertiary);font-size:9px;letter-spacing:.04em;position:relative;z-index:1}.toast{position:fixed;z-index:10;right:24px;bottom:24px;max-width:min(420px,calc(100% - 48px));padding:13px 16px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);box-shadow:var(--shadow);font-size:12px}.toast.error{color:var(--danger);border-color:#ffc1bd;background:var(--danger-soft)}@media (max-width: 920px){.hero{grid-template-columns:1fr;padding-top:76px}.hero-copy{max-width:none}.login-card{max-width:560px;width:100%}.content-grid{grid-template-columns:1fr}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric-grid article:nth-child(2){border-right:0}.metric-grid article:nth-child(-n+2){border-bottom:1px solid var(--line)}}@media (max-width: 620px){.topbar,main,footer{width:min(100% - 28px,1180px)}.topbar{height:74px}.brand small,.status-chip b,footer span:last-child{display:none}.status-chip{padding:0 9px}.hero{padding:52px 0 64px;gap:45px}.hero h1{font-size:43px}.hero-copy>p{font-size:14px}.protocol-grid article{padding:15px 11px}.protocol-grid strong{font-size:14px}.login-card,.surface{padding:21px}.console-heading{align-items:flex-start;gap:18px}.metric-grid{grid-template-columns:1fr}.metric-grid article{min-height:116px;border-right:0;border-bottom:1px solid var(--line)}.metric-grid article:last-child{border-bottom:0}.create-form>div{flex-direction:column}.create-form button{padding:10px}}@media (prefers-reduced-motion: reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important}} diff --git a/server sync/sync_server/static/assets/index-BS5pfOkS.css b/server sync/sync_server/static/assets/index-BS5pfOkS.css deleted file mode 100644 index bf1bd9f..0000000 --- a/server sync/sync_server/static/assets/index-BS5pfOkS.css +++ /dev/null @@ -1 +0,0 @@ -:root{color-scheme:dark;--bg: #06100d;--surface: rgba(13, 29, 24, .84);--surface-strong: #10241d;--line: rgba(169, 222, 196, .15);--line-strong: rgba(169, 222, 196, .28);--text: #edf7f2;--muted: #8fa99c;--mint: #72e0ad;--mint-bright: #a6f4cf;--purple: #a991ff;--danger: #ff8e8e;--warning: #f5ca72;--shadow: 0 26px 90px rgba(0, 0, 0, .34);font-family:Inter,Segoe UI,PingFang SC,Microsoft YaHei,system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;min-height:100%;background:var(--bg)}body{margin:0;min-height:100vh;color:var(--text);background:linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.018) 1px,transparent 1px),radial-gradient(circle at 48% 0%,#15372c 0,var(--bg) 46%);background-size:52px 52px,52px 52px,auto;overflow-x:hidden}button,input{font:inherit}button{color:inherit}button:focus-visible,input:focus-visible{outline:2px solid var(--mint);outline-offset:3px}button:disabled{cursor:default;opacity:.5}.ambient{position:fixed;border-radius:50%;filter:blur(80px);pointer-events:none;opacity:.16}.ambient-one{width:420px;height:420px;background:var(--mint);top:-220px;right:8%}.ambient-two{width:360px;height:360px;background:var(--purple);bottom:-220px;left:-100px;opacity:.1}.topbar{width:min(1280px,calc(100% - 48px));height:88px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);position:relative;z-index:2}.brand{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:13px}.brand>span:last-child{display:grid;gap:1px}.brand strong{font-size:17px;letter-spacing:-.02em}.brand small{color:var(--muted);font-size:11px;letter-spacing:.12em;text-transform:uppercase}.brand-mark{width:34px;height:34px;display:grid;place-items:center;position:relative;transform:rotate(30deg)}.brand-mark i{position:absolute;display:block;border:1.5px solid var(--mint);border-radius:4px}.brand-mark i:nth-child(1){width:25px;height:25px;opacity:.45}.brand-mark i:nth-child(2){width:17px;height:17px;opacity:.72}.brand-mark i:nth-child(3){width:8px;height:8px;background:var(--mint);box-shadow:0 0 20px #72e0adcc}.service-strip{display:flex;align-items:center;gap:9px}.status-chip{min-height:34px;padding:0 12px;border:1px solid var(--line);border-radius:999px;display:flex;align-items:center;gap:7px;background:#07120fb3}.status-chip i{width:7px;height:7px;border-radius:50%;background:var(--warning);box-shadow:0 0 12px currentColor}.status-chip b{color:var(--muted);font-size:11px;font-weight:500}.status-chip em{font-size:11px;font-style:normal}.status-chip.ok i{background:var(--mint)}.status-chip.bad i{background:var(--danger)}.status-chip.bad em{color:var(--danger)}.icon-button{width:36px;height:36px;border:1px solid var(--line);border-radius:10px;display:grid;place-items:center;background:#0e1f19b3;cursor:pointer;transition:.18s ease}.icon-button:hover{border-color:var(--line-strong);background:var(--surface-strong);transform:translateY(-1px)}.icon-button svg{width:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}main{width:min(1180px,calc(100% - 48px));margin:0 auto;position:relative;z-index:1}.hero{min-height:calc(100vh - 150px);display:grid;grid-template-columns:minmax(0,1.25fr) minmax(360px,.75fr);gap:clamp(48px,8vw,116px);align-items:center;padding:64px 0 82px}.hero-copy{max-width:680px}.eyebrow{color:var(--mint);font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;display:flex;align-items:center;gap:10px}.eyebrow span{width:24px;height:1px;background:var(--mint);box-shadow:0 0 10px var(--mint)}.hero h1,.console-heading h1{margin:20px 0;letter-spacing:-.06em;line-height:.99;font-size:clamp(50px,6.4vw,86px);font-weight:620}.hero h1 em{font-style:normal;color:transparent;background:linear-gradient(95deg,var(--mint-bright),#82d8c1 47%,var(--purple));background-clip:text;-webkit-background-clip:text}.hero-copy>p{max-width:620px;color:var(--muted);font-size:16px;line-height:1.85}.protocol-grid{display:grid;grid-template-columns:repeat(3,1fr);margin-top:42px;border:1px solid var(--line);border-radius:16px;overflow:hidden;background:#08161173;-webkit-backdrop-filter:blur(15px);backdrop-filter:blur(15px)}.protocol-grid article{padding:19px 22px;display:grid;gap:5px;border-right:1px solid var(--line)}.protocol-grid article:last-child{border-right:0}.protocol-grid strong{font-size:18px;font-weight:600}.protocol-grid span{color:var(--muted);font-size:11px}.login-card{padding:31px;border:1px solid var(--line-strong);border-radius:24px;background:linear-gradient(145deg,#122820f0,#081511e6);box-shadow:var(--shadow);position:relative;overflow:hidden;-webkit-backdrop-filter:blur(25px);backdrop-filter:blur(25px)}.card-glow{position:absolute;width:210px;height:210px;right:-100px;top:-120px;border-radius:50%;background:var(--mint);filter:blur(60px);opacity:.16}.card-heading{display:flex;gap:14px;align-items:center;margin-bottom:28px;position:relative}.card-heading p,.surface-heading p{margin:0 0 3px;color:var(--mint);font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}.card-heading h2,.surface-heading h2{margin:0;font-size:21px;letter-spacing:-.025em}.lock-mark{width:43px;height:43px;border-radius:13px;display:grid;place-items:center;background:#72e0ad1a;color:var(--mint);border:1px solid rgba(114,224,173,.18)}.lock-mark svg{width:21px;fill:none;stroke:currentColor;stroke-width:1.7}form{position:relative}.login-card form{display:grid;gap:17px}label{display:grid;gap:7px;color:var(--muted);font-size:11px;font-weight:600;letter-spacing:.04em}input{width:100%;color:var(--text);background:#030c0994;border:1px solid var(--line);border-radius:11px;padding:12px 13px;transition:border-color .18s,background .18s}input::placeholder{color:#5f786c}input:hover,input:focus{border-color:#72e0ad75;background:#030c09cc}.primary-button{width:100%;margin-top:5px;padding:13px 16px;border:0;border-radius:11px;display:flex;justify-content:center;align-items:center;gap:8px;color:#062015;background:linear-gradient(100deg,var(--mint-bright),var(--mint));font-weight:750;cursor:pointer;box-shadow:0 10px 35px #72e0ad29;transition:.18s ease}.primary-button:hover{transform:translateY(-1px);filter:brightness(1.05)}.primary-button svg{width:18px;fill:none;stroke:currentColor;stroke-width:2}.privacy-note{margin:18px 0 0;color:#718b7e;font-size:10px;line-height:1.5;display:flex;gap:8px}.privacy-note span{color:var(--mint);font-size:7px;margin-top:3px}.console-view{padding:64px 0 90px;min-height:calc(100vh - 150px)}.console-heading{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:38px}.console-heading h1{font-size:clamp(42px,5vw,64px);margin:13px 0 7px}.console-heading p{margin:0;color:var(--muted)}.secondary-button{padding:10px 15px;border-radius:10px;border:1px solid var(--line-strong);background:transparent;cursor:pointer}.secondary-button:hover{border-color:var(--mint);color:var(--mint-bright)}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:#08161185;border-radius:18px;overflow:hidden;margin-bottom:22px}.metric-grid article{min-height:134px;padding:23px;display:flex;flex-direction:column;border-right:1px solid var(--line)}.metric-grid article:last-child{border-right:0}.metric-grid span{color:var(--muted);font-size:11px}.metric-grid strong{margin:12px 0 8px;font-size:27px;font-weight:620;letter-spacing:-.04em}.metric-grid small{color:#668075;font-size:10px}.content-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:22px}.surface{border:1px solid var(--line);border-radius:18px;background:var(--surface);padding:25px;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.surface-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.secure-badge{color:var(--mint);background:#72e0ad14;border:1px solid rgba(114,224,173,.18);padding:6px 9px;border-radius:999px;font-size:9px;letter-spacing:.08em}.surface-intro{margin:-9px 0 19px;color:var(--muted);font-size:12px;line-height:1.65}.create-form{margin-bottom:20px;padding:16px;border-radius:13px;background:#040f0b6b;border:1px solid var(--line)}.create-form>div{display:flex;gap:8px;margin-top:8px}.create-form input{padding:10px 12px}.create-form button{border:0;border-radius:9px;padding:0 17px;color:#082016;background:var(--mint);font-weight:700;cursor:pointer}.vault-list,.device-list{display:grid;gap:9px}.vault-item,.device-item{min-width:0;border:1px solid var(--line);background:#06130e7a;border-radius:13px;padding:15px 16px;display:flex;align-items:center;gap:13px}.vault-symbol,.device-symbol{width:38px;height:38px;flex:0 0 auto;display:grid;place-items:center;border-radius:11px;background:#72e0ad14;color:var(--mint);font-weight:700}.device-symbol{color:var(--purple);background:#a991ff14}.item-copy{min-width:0;flex:1}.item-copy strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.item-copy small{color:var(--muted);font-size:10px}progress{display:block;width:100%;height:4px;margin-top:9px;border:0;border-radius:99px;overflow:hidden;accent-color:var(--mint)}progress::-webkit-progress-bar{background:#ffffff0f}progress::-webkit-progress-value{background:linear-gradient(90deg,var(--mint),var(--purple))}.item-side{text-align:right;flex:0 0 auto}.item-side strong{display:block;font-size:12px}.item-side small{color:var(--muted);font-size:9px}.danger-button{border:1px solid rgba(255,142,142,.25);color:var(--danger);background:#ff8e8e0d;border-radius:8px;padding:7px 9px;cursor:pointer;font-size:10px}.danger-button:hover{background:#ff8e8e1f}.revoked{opacity:.48}.empty-state{min-height:100px;display:grid;place-items:center;color:var(--muted);border:1px dashed var(--line);border-radius:12px;font-size:12px}footer{width:min(1280px,calc(100% - 48px));min-height:62px;margin:0 auto;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;color:#597166;font-size:9px;letter-spacing:.04em;position:relative;z-index:1}.toast{position:fixed;z-index:10;right:24px;bottom:24px;max-width:min(420px,calc(100% - 48px));padding:13px 16px;border:1px solid var(--line-strong);border-radius:11px;background:#142b23;box-shadow:var(--shadow);font-size:12px}.toast.error{color:#ffd2d2;border-color:#ff8e8e59;background:#321a1a}@media (max-width: 920px){.hero{grid-template-columns:1fr;padding-top:76px}.hero-copy{max-width:none}.login-card{max-width:560px;width:100%}.content-grid{grid-template-columns:1fr}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric-grid article:nth-child(2){border-right:0}.metric-grid article:nth-child(-n+2){border-bottom:1px solid var(--line)}}@media (max-width: 620px){.topbar,main,footer{width:min(100% - 28px,1180px)}.topbar{height:74px}.brand small,.status-chip b,footer span:last-child{display:none}.status-chip{padding:0 9px}.hero{padding:52px 0 64px;gap:45px}.hero h1{font-size:43px}.hero-copy>p{font-size:14px}.protocol-grid article{padding:15px 11px}.protocol-grid strong{font-size:14px}.login-card,.surface{padding:21px}.console-heading{align-items:flex-start;gap:18px}.metric-grid{grid-template-columns:1fr}.metric-grid article{min-height:116px;border-right:0;border-bottom:1px solid var(--line)}.metric-grid article:last-child{border-bottom:0}.create-form>div{flex-direction:column}.create-form button{padding:10px}}@media (prefers-reduced-motion: reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important}} diff --git a/server sync/sync_server/static/assets/index-CV7tuz7b.js b/server sync/sync_server/static/assets/index-CsQwWg1J.js similarity index 100% rename from server sync/sync_server/static/assets/index-CV7tuz7b.js rename to server sync/sync_server/static/assets/index-CsQwWg1J.js diff --git a/server sync/sync_server/static/index.html b/server sync/sync_server/static/index.html index 9e737fc..71990d1 100644 --- a/server sync/sync_server/static/index.html +++ b/server sync/sync_server/static/index.html @@ -6,8 +6,8 @@ OpenNexus Sync Console - - + +