From 4c79e940d2d7f8d5740c84af4d33fd8df8565339 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 12:23:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OpenNexus=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=20Core=20=E4=B8=8E=20Stronghold=20=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- backend/app/config.py | 2 +- backend/app/container.py | 7 +- backend/app/host_bridge.py | 67 + backend/app/providers/credentials.py | 71 +- backend/app/services/coordination.py | 2 + backend/app/sidecar.py | 151 +++ backend/pyproject.toml | 3 + backend/sidecar_entry.py | 5 + backend/tests/test_api.py | 2 +- backend/tests/test_credentials.py | 13 + backend/tests/test_sidecar_auth.py | 107 ++ backend/uv.lock | 92 ++ docs/README.md | 4 +- .../第三阶段生产化工程规划与验收目标.md | 6 +- docs/contracts/Host-v1契约.md | 8 +- .../OpenNexus生产化实施进度-2026-09-08.md | 36 + frontend/index.html | 2 +- frontend/src-tauri/Cargo.lock | 1096 ++++++++++++++++- frontend/src-tauri/Cargo.toml | 13 + frontend/src-tauri/build.rs | 20 + frontend/src-tauri/capabilities/main.json | 7 + .../autogenerated/core_stream.toml | 11 + .../autogenerated/core_stream_cancel.toml | 11 + .../credentials_change_password.toml | 11 + .../autogenerated/credentials_import.toml | 11 + .../autogenerated/credentials_lock.toml | 11 + .../autogenerated/credentials_status.toml | 11 + .../autogenerated/credentials_unlock.toml | 11 + frontend/src-tauri/src/core.rs | 471 +++++++ frontend/src-tauri/src/credentials.rs | 710 +++++++++++ frontend/src-tauri/src/lib.rs | 3 + frontend/src-tauri/src/main.rs | 350 +++++- frontend/src-tauri/src/runtime_compat.rs | 39 + frontend/src-tauri/tauri.bundle.conf.json | 7 + frontend/src-tauri/tauri.conf.json | 6 +- frontend/src-tauri/tests/core_bundle.rs | 21 + frontend/src-tauri/tests/core_process.rs | 123 ++ .../tests/fixtures/fernet-python.json | 208 ++++ frontend/src/components/common/TitleBar.vue | 4 +- frontend/src/features/mcp/McpServersView.vue | 4 +- frontend/src/features/media/MediaView.vue | 27 +- .../settings/CredentialVaultSettings.vue | 68 + .../src/features/settings/SettingsView.vue | 3 + frontend/src/features/vault/VaultEntry.vue | 2 +- frontend/src/router/index.ts | 2 +- frontend/src/services/apiClient.ts | 20 +- frontend/src/services/mediaService.ts | 5 + .../src/services/platform/coreStream.spec.ts | 38 + frontend/src/services/platform/coreStream.ts | 52 + frontend/src/services/sseClient.ts | 7 +- frontend/src/stores/theme.ts | 4 +- scripts/build-core.py | 46 + server sync/sync_server/__main__.py | 18 +- server sync/sync_server/app.py | 132 +- server sync/sync_server/database.py | 8 +- server sync/sync_server/maintenance.py | 26 + server sync/sync_server/storage.py | 44 +- server sync/tests/test_production_storage.py | 99 ++ 59 files changed, 4242 insertions(+), 102 deletions(-) create mode 100644 backend/app/host_bridge.py create mode 100644 backend/app/sidecar.py create mode 100644 backend/sidecar_entry.py create mode 100644 backend/tests/test_sidecar_auth.py create mode 100644 docs/development/OpenNexus生产化实施进度-2026-09-08.md create mode 100644 frontend/src-tauri/permissions/autogenerated/core_stream.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/core_stream_cancel.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_change_password.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_import.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_lock.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_status.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_unlock.toml create mode 100644 frontend/src-tauri/src/core.rs create mode 100644 frontend/src-tauri/src/credentials.rs create mode 100644 frontend/src-tauri/src/runtime_compat.rs create mode 100644 frontend/src-tauri/tauri.bundle.conf.json create mode 100644 frontend/src-tauri/tests/core_bundle.rs create mode 100644 frontend/src-tauri/tests/core_process.rs create mode 100644 frontend/src-tauri/tests/fixtures/fernet-python.json create mode 100644 frontend/src/features/settings/CredentialVaultSettings.vue create mode 100644 frontend/src/services/platform/coreStream.spec.ts create mode 100644 frontend/src/services/platform/coreStream.ts create mode 100644 scripts/build-core.py create mode 100644 server sync/sync_server/maintenance.py create mode 100644 server sync/tests/test_production_storage.py diff --git a/README.md b/README.md index 004f188..944acbc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Notes Agent(暂命名) 团队开发说明 +# OpenNexus 团队开发说明 > 第二阶段收尾(开发分支,2026-09-07):标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。 @@ -6,10 +6,12 @@ > 第三阶段分支状态(2026-09-07):已加入 Tauri/Rust 原生 Vault 预览、Sync v1 服务原型、签名社区目录原型和七类社区入口。完整 Sidecar、Stronghold、生产插件隔离、同步客户端、升级回滚及三平台发布门禁仍未交付;详见[第三阶段实施与验收记录](docs/development/第三阶段实施与验收记录.md)。 -NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。 +OpenNexus 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。 截至 2026-09-06,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。 +> 正式名称:OpenNexus(2026-09-08)。旧应用标识 `cc.kronecker.notesagent`、数据库/凭据路径和协议标识保留兼容,不因品牌更名创建新数据目录。 + ## 目录 ```text diff --git a/backend/app/config.py b/backend/app/config.py index 3d3d753..274dfa8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,7 +32,7 @@ class Settings: def get_settings() -> Settings: data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data"))) return Settings( - name=os.getenv("APP_NAME", "Notes Agent AI Core"), + name=os.getenv("APP_NAME", "OpenNexus AI Core"), version=os.getenv("APP_VERSION", "0.1.0"), environment=os.getenv("APP_ENVIRONMENT", "development"), host=os.getenv("APP_HOST", "127.0.0.1"), diff --git a/backend/app/container.py b/backend/app/container.py index 1f2b249..54cd0d6 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -13,6 +13,7 @@ from app.providers.credentials import ( ChainedCredentialResolver, EncryptedCredentialStore, EnvironmentCredentialResolver, + HostCredentialStore, ) @@ -21,7 +22,7 @@ class ApplicationContainer: providers: ProviderRegistry provider_factory: ProviderFactory model_routing: ModelRoutingService - credentials: EncryptedCredentialStore + credentials: EncryptedCredentialStore | HostCredentialStore tools: ToolRegistry permissions: PermissionManager skills: SkillRuntime @@ -32,9 +33,9 @@ class ApplicationContainer: def build_container() -> ApplicationContainer: settings = get_settings() - credentials = EncryptedCredentialStore() + credentials = HostCredentialStore() if settings.environment == "desktop" else EncryptedCredentialStore() provider_factory = ProviderFactory( - ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) + credentials if settings.environment == "desktop" else ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) ) providers = ProviderRegistry(provider_factory) providers.register( diff --git a/backend/app/host_bridge.py b/backend/app/host_bridge.py new file mode 100644 index 0000000..94d109e --- /dev/null +++ b/backend/app/host_bridge.py @@ -0,0 +1,67 @@ +"""Synchronous, bounded RPC over the inherited Host pipes (never HTTP or env secrets).""" +from __future__ import annotations +import json +import queue +import threading +import uuid + + +class HostBridge: + def __init__(self, reader, writer): + self.reader, self.writer = reader, writer + self.pending = {} + self.lock = threading.Lock() + self.closed = threading.Event() + + def call(self, method, **params): + request_id = uuid.uuid4().hex + result = queue.Queue(maxsize=1) + payload = json.dumps({"rpc": method, "request_id": request_id, "params": params}, separators=(",", ":")) + if len(payload.encode()) > 131072: + raise RuntimeError("HOST_REQUEST_TOO_LARGE") + with self.lock: + if self.closed.is_set(): + raise RuntimeError("HOST_UNAVAILABLE") + self.pending[request_id] = result + try: + self.writer.write(payload + "\n") + self.writer.flush() + except Exception: + self.pending.pop(request_id, None) + raise RuntimeError("HOST_UNAVAILABLE") from None + try: + response = result.get(timeout=30) + if response.get("error"): + raise RuntimeError(response["error"]) + return response.get("result") + except queue.Empty: + raise RuntimeError("HOST_TIMEOUT") from None + finally: + with self.lock: + self.pending.pop(request_id, None) + + def listen(self, on_disconnect): + try: + while line := self.reader.readline(131073): + if len(line) > 131072: + break + message = json.loads(line) + with self.lock: + target = self.pending.get(message.get("request_id")) + if target is not None: + try: + target.put_nowait(message) + except queue.Full: + pass + finally: + self.closed.set() + with self.lock: + for result in self.pending.values(): + try: + result.put_nowait({"error": "HOST_UNAVAILABLE"}) + except queue.Full: + pass + on_disconnect() + + +active: HostBridge | None = None diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index 8eec9a2..ce7eeb0 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -4,6 +4,7 @@ import json import os import re import threading +from contextlib import contextmanager from pathlib import Path from typing import ClassVar, Protocol @@ -24,6 +25,37 @@ class CredentialResolver(Protocol): def resolve(self, credential_id: str | None) -> str | None: ... +class HostCredentialStore: + """Desktop-only adapter. It cannot fall back to Fernet or environment keys.""" + @staticmethod + def _call(method, **params): + from app.host_bridge import active + if active is None: + raise CredentialStoreError("HOST_UNAVAILABLE") + try: + return active.call("credentials." + method, **params) + except RuntimeError as exc: + raise CredentialStoreError(str(exc)) from None + + def resolve(self, credential_id): + return self._call("resolve", id=credential_id) if credential_id else None + + def has(self, credential_id): + return bool(self._call("has", id=credential_id)) + + def put(self, credential_id, secret): + self._call("put", id=credential_id, secret=secret) + + def delete(self, credential_id): + return bool(self._call("delete", id=credential_id)) + + def delete_many(self, credential_ids): + return set(self._call("delete_many", ids=credential_ids)) + + def move_many(self, replacements): + self._call("move_many", replacements=replacements) + + def validate_provider_credential_id(credential_id: str | None) -> None: """阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。""" @@ -62,6 +94,33 @@ class EncryptedCredentialStore: def __init__(self) -> None: self._lock = threading.RLock() + @contextmanager + def _operation_lock(self): + with self._lock: + key_path, _ = self._paths() + key_path.parent.mkdir(parents=True, exist_ok=True) + with (key_path.parent / ".migration.lock").open("a+b") as stream: + stream.seek(0) + try: + if os.name == "nt": + import msvcrt + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raise CredentialStoreError("MIGRATION_SOURCE_BUSY") from None + try: + if (key_path.parent / ".opennexus-owner.json").exists(): + raise CredentialStoreError("CREDENTIAL_OWNER_DESKTOP") + yield + finally: + stream.seek(0) + if os.name == "nt": + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + @staticmethod def _validate_id(credential_id: str) -> None: if not _CREDENTIAL_ID.fullmatch(credential_id): @@ -155,7 +214,7 @@ class EncryptedCredentialStore: self._validate_id(credential_id) if not secret: raise CredentialStoreError("Credential secret cannot be empty.") - with self._lock: + with self._operation_lock(): tokens = self._read_tokens() token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii") tokens[credential_id] = token @@ -165,7 +224,7 @@ class EncryptedCredentialStore: if not credential_id: return None self._validate_id(credential_id) - with self._lock: + with self._operation_lock(): token = self._read_tokens().get(credential_id) if token is None: return None @@ -176,12 +235,12 @@ class EncryptedCredentialStore: def has(self, credential_id: str) -> bool: self._validate_id(credential_id) - with self._lock: + with self._operation_lock(): return credential_id in self._read_tokens() def delete(self, credential_id: str) -> bool: self._validate_id(credential_id) - with self._lock: + with self._operation_lock(): tokens = self._read_tokens() removed = tokens.pop(credential_id, None) is not None if removed: @@ -193,7 +252,7 @@ class EncryptedCredentialStore: for credential_id in credential_ids: self._validate_id(credential_id) - with self._lock: + with self._operation_lock(): tokens = self._read_tokens() removed = { credential_id @@ -212,7 +271,7 @@ class EncryptedCredentialStore: for old_id, new_id in replacements.items(): self._validate_id(old_id) self._validate_id(new_id) - with self._lock: + with self._operation_lock(): tokens = self._read_tokens() changed = False for old_id, new_id in replacements.items(): diff --git a/backend/app/services/coordination.py b/backend/app/services/coordination.py index 6ec5ffb..45381f5 100644 --- a/backend/app/services/coordination.py +++ b/backend/app/services/coordination.py @@ -11,6 +11,8 @@ def web_vault_ownership(): """与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。""" from app.config import get_settings from app.errors import ApiError + if get_settings().environment == 'desktop': + raise ApiError(409, 'WORKSPACE_OWNER_DESKTOP', '桌面笔记写入必须通过 Rust Host') root = get_settings().vault_path managed = root / '.ainote' if managed.is_symlink() or (hasattr(managed, 'is_junction') and managed.is_junction()): diff --git a/backend/app/sidecar.py b/backend/app/sidecar.py new file mode 100644 index 0000000..0b30abf --- /dev/null +++ b/backend/app/sidecar.py @@ -0,0 +1,151 @@ +"""Authenticated desktop entry point. Bootstrap secrets travel only over stdin. + +stdout is reserved for the bounded handshake; application output goes to stderr. +The parent keeps stdin open for the lifetime of the Core. EOF shuts it down. +""" +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import os +from pathlib import Path +import re +import socket +import sys +import threading + +PROTOCOL = 1 +MAX_BOOTSTRAP = 16384 + + +def bootstrap(line: bytes) -> dict: + if len(line) > MAX_BOOTSTRAP or not line.endswith(b"\n"): + raise ValueError("CORE_BOOTSTRAP_INVALID") + try: + value = json.loads(line) + if value["protocol"] != PROTOCOL: + raise ValueError("PROTOCOL_INCOMPATIBLE") + for key in ("secret", "challenge", "generation"): + if not isinstance(value[key], str) or not re.fullmatch(r"[0-9a-f]{64}", value[key]): + raise ValueError("CORE_BOOTSTRAP_INVALID") + if not Path(value["data_dir"]).is_absolute(): + raise ValueError("CORE_BOOTSTRAP_INVALID") + if type(value.get("launcher_pid")) is not int or not 0 < value["launcher_pid"] <= 0xFFFFFFFF: + raise ValueError("CORE_BOOTSTRAP_INVALID") + except (KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError("CORE_BOOTSTRAP_INVALID") from exc + return value + + +def proof(secret: str, challenge: str, generation: str, pid: int, port: int, launcher_pid: int) -> str: + message = f"{PROTOCOL}:{challenge}:{generation}:{launcher_pid}:{pid}:{port}".encode("ascii") + return hmac.new(bytes.fromhex(secret), message, hashlib.sha256).hexdigest() + + +class SessionAuth: + """Outermost ASGI layer: unauthenticated input never reaches business logs.""" + + def __init__(self, app, secret: str, generation: str, port: int): + self.app = app + self.expected = f"Bearer {secret}".encode("ascii") + self.generation = generation.encode("ascii") + self.host = f"127.0.0.1:{port}".encode("ascii") + + async def __call__(self, scope, receive, send): + if scope["type"] not in {"http", "websocket"}: + return await self.app(scope, receive, send) + headers = scope.get("headers", []) + def single(name): + values = [v for k, v in headers if k.lower() == name] + return values[0] if len(values) == 1 else b"" + authorized = ( + hmac.compare_digest(single(b"authorization"), self.expected) + and hmac.compare_digest(single(b"x-core-generation"), self.generation) + and single(b"host") == self.host + # Host transport does not send Origin. Browser traffic is never trusted. + and not any(k.lower() == b"origin" for k, _ in headers) + ) + if not authorized: + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + else: + body = b'{"error":{"code":"AUTH_REQUIRED"}}' + await send({"type": "http.response.start", "status": 401, + "headers": [(b"content-type", b"application/json"), + (b"cache-control", b"no-store")]}) + await send({"type": "http.response.body", "body": body}) + return + await self.app(scope, receive, send) + + +def main() -> int: + channel = sys.stdin.buffer + try: + config = bootstrap(channel.readline(MAX_BOOTSTRAP + 1)) + except (ValueError, OSError): + print("CORE_BOOTSTRAP_INVALID", file=sys.stderr) + return 2 + handshake = sys.stdout + sys.stdout = sys.stderr + root = Path(config["data_dir"]) + # Override every data path before importing the application/container. + os.environ.update({ + "APP_ENVIRONMENT": "desktop", "APP_DATA_DIR": str(root), + "APP_DB_PATH": str(root / "app.db"), + "APP_VAULT_PATH": str(root / "unbound-vault"), + "APP_ATTACHMENTS_PATH": str(root / "attachments"), + "APP_EXPORTS_PATH": str(root / "exports"), + "APP_BENCHMARK_DATASETS_PATH": str(root / "benchmarks"), + }) + import uvicorn + from app import host_bridge + host_bridge.active = host_bridge.HostBridge(channel, handshake) + from app.main import app + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(128) + port = sock.getsockname()[1] + app.openapi_url = None + app.router.routes[:] = [r for r in app.router.routes + if getattr(r, "path", "") not in {"/docs", "/redoc", "/openapi.json", "/docs/oauth2-redirect"}] + server = uvicorn.Server(uvicorn.Config( + SessionAuth(app, config["secret"], config["generation"], port), + log_config=None, access_log=False, lifespan="on", timeout_graceful_shutdown=5, + )) + + def watch_parent(): + host_bridge.active.listen(lambda: setattr(server, "should_exit", True)) + + threading.Thread(target=watch_parent, name="host-lifetime", daemon=True).start() + + async def run(): + task = asyncio.create_task(server.serve(sockets=[sock])) + for _ in range(3000): + if task.done(): + await task + return + if server.started: + payload = {"protocol": PROTOCOL, "pid": os.getpid(), "port": port, + "generation": config["generation"], "launcher_pid": config["launcher_pid"], + "proof": proof(config["secret"], config["challenge"], + config["generation"], os.getpid(), port, config["launcher_pid"])} + handshake.write(json.dumps(payload, separators=(",", ":")) + "\n") + handshake.flush() + await task + return + await asyncio.sleep(0.01) + server.should_exit = True + await task + raise RuntimeError("CORE_READY_TIMEOUT") + try: + asyncio.run(run()) + finally: + sock.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d500c1d..e78042d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,9 @@ dependencies = [ dev = [ "pytest>=8.4,<9.0", ] +packaging = [ + "pyinstaller>=6.16,<7", +] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/backend/sidecar_entry.py b/backend/sidecar_entry.py new file mode 100644 index 0000000..e7457a2 --- /dev/null +++ b/backend/sidecar_entry.py @@ -0,0 +1,5 @@ +"""PyInstaller entry; the app package is included by the build script.""" +from app.sidecar import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5be0481..ed4327f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -258,7 +258,7 @@ def test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive( def test_service_status() -> None: response = asyncio.run(service_status()) - assert response.name == "Notes Agent AI Core" + assert response.name == "OpenNexus AI Core" assert response.status == "ok" diff --git a/backend/tests/test_credentials.py b/backend/tests/test_credentials.py index ca09176..3075699 100644 --- a/backend/tests/test_credentials.py +++ b/backend/tests/test_credentials.py @@ -36,6 +36,19 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No assert store.resolve("deepseek") is None +def test_migrated_fernet_owner_blocks_old_reads_and_writes() -> None: + store = EncryptedCredentialStore() + store.put("fixture", "test-secret") + directory = get_settings().data_dir / "credentials" + before = (directory / "credentials.json").read_bytes() + (directory / ".opennexus-owner.json").write_text('{"state":"switched"}') + for operation in [lambda: store.resolve("fixture"), lambda: store.has("fixture"), + lambda: store.put("fixture", "changed"), lambda: store.delete("fixture")]: + with pytest.raises(CredentialStoreError, match="CREDENTIAL_OWNER_DESKTOP"): + operation() + assert (directory / "credentials.json").read_bytes() == before + + def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None: store = EncryptedCredentialStore() store.put("plugin.first", "first") diff --git a/backend/tests/test_sidecar_auth.py b/backend/tests/test_sidecar_auth.py new file mode 100644 index 0000000..05ce74f --- /dev/null +++ b/backend/tests/test_sidecar_auth.py @@ -0,0 +1,107 @@ +import asyncio +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import threading +import urllib.error +import urllib.request + +import pytest + +from app.sidecar import SessionAuth, bootstrap, proof + + +def test_bootstrap_is_bounded_and_requires_session_entropy(tmp_path): + data = dict(protocol=1, secret="01" * 32, challenge="02" * 32, + generation="03" * 32, data_dir=str(tmp_path), launcher_pid=123) + assert bootstrap(json.dumps(data).encode() + b"\n") == data + for invalid in [b"{}\n", b"x" * 16385, b"{}", b"null\n"]: + with pytest.raises(ValueError): + bootstrap(invalid) + data["secret"] = "short" + with pytest.raises(ValueError): + bootstrap(json.dumps(data).encode() + b"\n") + + +def test_session_auth_covers_every_route_and_rejects_duplicate_headers(): + calls = [] + async def app(scope, receive, send): + calls.append(scope["path"]) + await send({"type": "http.response.start", "status": 204, "headers": []}) + auth = SessionAuth(app, "ab" * 32, "cd" * 32, 4567) + valid = [(b"host", b"127.0.0.1:4567"), + (b"authorization", ("Bearer " + "ab" * 32).encode()), + (b"x-core-generation", ("cd" * 32).encode())] + async def request(headers, path): + messages = [] + async def send(message): + messages.append(message) + await auth({"type": "http", "headers": headers, "path": path}, None, send) + return messages[0]["status"] + for path in ["/health", "/api/status", "/api/events", "/api/export/file", "/docs", "/unknown"]: + for bad in [[], valid[:2], valid + [valid[1]], + valid + [(b"origin", b"tauri://localhost")], + [(b"host", b"evil.test")] + valid[1:], + valid[:2] + [(b"x-core-generation", b"old")]]: + assert asyncio.run(request(bad, path)) == 401 + assert asyncio.run(request(valid, path)) == 204 + assert len(calls) == 6 + + +def test_handshake_proof_binds_port_pid_generation_and_challenge(): + args = ["01" * 32, "02" * 32, "03" * 32, 123, 4567, 123] + expected = proof(*args) + assert len(expected) == 64 + for i in range(1, len(args)): + changed = args.copy() + changed[i] = "04" * 32 if isinstance(args[i], str) else args[i] + 1 + assert proof(*changed) != expected + + +def test_real_sidecar_bootstrap_auth_and_parent_eof(tmp_path): + config = dict(protocol=1, secret="01" * 32, challenge="02" * 32, + generation="03" * 32, data_dir=str(tmp_path / "core")) + executable = os.environ.get("OPENNEXUS_CORE_TEST_BINARY") + command = [executable] if executable else [sys.executable, "-m", "app.sidecar"] + diagnostics = (tmp_path / "core-stderr.log").open("wb") + process = subprocess.Popen(command, + cwd=Path(__file__).resolve().parents[1], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=diagnostics, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + try: + config["launcher_pid"] = process.pid + process.stdin.write(json.dumps(config).encode() + b"\n") + process.stdin.flush() + received = queue.Queue() + threading.Thread(target=lambda: received.put(process.stdout.readline(16385)), daemon=True).start() + line = received.get(timeout=30) + assert line, (tmp_path / "core-stderr.log").read_text(encoding="utf-8", errors="replace")[-4000:] + ready = json.loads(line) + assert ready["launcher_pid"] == process.pid + assert ready["pid"] > 0 + assert ready["proof"] == proof(config["secret"], config["challenge"], config["generation"], + ready["pid"], ready["port"], process.pid) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + url = f'http://127.0.0.1:{ready["port"]}' + with pytest.raises(urllib.error.HTTPError) as error: + opener.open(url + "/health", timeout=5) + assert error.value.code == 401 + request = urllib.request.Request(url + "/health", headers={ + "Authorization": "Bearer " + config["secret"], + "X-Core-Generation": config["generation"], + }) + with opener.open(request, timeout=5) as response: + assert json.load(response)["status"] == "ok" + process.stdin.close() + assert process.wait(timeout=10) == 0 + finally: + if not process.stdin.closed: + process.stdin.close() + if process.poll() is None: + process.kill() + process.wait(timeout=10) + diagnostics.close() diff --git a/backend/uv.lock b/backend/uv.lock index 7b99211..7c50f9f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -1011,6 +1020,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "matplotlib" version = "3.11.1" @@ -1110,6 +1131,9 @@ dependencies = [ dev = [ { name = "pytest" }, ] +packaging = [ + { name = "pyinstaller" }, +] [package.metadata] requires-dist = [ @@ -1131,6 +1155,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.4,<9.0" }] +packaging = [{ name = "pyinstaller", specifier = ">=6.16,<7" }] [[package]] name = "numpy" @@ -1308,6 +1333,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1568,6 +1602,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1627,6 +1701,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1832,6 +1915,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/docs/README.md b/docs/README.md index 8054950..2a26352 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,10 @@ -# NotesAgent 文档索引 +# OpenNexus 文档索引 > 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。 本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。 -当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录的可测试原型;它仍缺少 Sidecar、Stronghold、生产插件隔离、完整同步客户端和发布门禁,不能作为第三阶段发布候选。状态与证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。 +当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录原型,并开始接入受认证的 Sidecar 与 Stronghold。生产插件隔离、完整同步客户端和发布门禁仍未完成,不能作为发布候选。最新状态见[OpenNexus 生产化实施进度](development/OpenNexus生产化实施进度-2026-09-08.md),历史证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。 仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。 diff --git a/docs/architecture/第三阶段生产化工程规划与验收目标.md b/docs/architecture/第三阶段生产化工程规划与验收目标.md index 7059c51..943f2e5 100644 --- a/docs/architecture/第三阶段生产化工程规划与验收目标.md +++ b/docs/architecture/第三阶段生产化工程规划与验收目标.md @@ -1,6 +1,6 @@ # 第三阶段五项生产化能力:工程规划与验收目标 -日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`。状态:**仅规划,五项均未通过生产验收;本文不交付功能、安装包或运行证据。** +日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`。初始规划提交:`2c4287e`。用户随后授权全量实施,并确定正式名称为 **OpenNexus**。状态:**实施中,五项均未通过完整生产验收**;实际证据见[实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。 ## 1. 范围、文档优先级与交付口径 @@ -8,7 +8,7 @@ “通过”要求实现、自动化报告、平台实测和恢复演练同时满足。旧记录中的测试数量不继承为本计划证据。五项完成不代表多窗口、全部社区内容能力、OCR/音频质量等第三阶段其他退出项完成。 -本次仅允许文档提交。实施期间测试也只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。 +初始规划轮仅允许文档提交;后续实施已获用户授权。实施期间测试只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。 ## 2. 现状审计与差距 @@ -256,7 +256,7 @@ runner输出JUnit、逐ID JSON、耗时/峰值内存、进程树/拒绝访问计 ## 14. 非目标与仍需产品确认的事项 -明确非目标:本次任务功能实现;移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。 +明确非目标:移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。 以下在P0由产品/安全/运维共同确认,当前采用表中工程默认值推进设计,不把未答复当批准: diff --git a/docs/contracts/Host-v1契约.md b/docs/contracts/Host-v1契约.md index 0b53990..4e693db 100644 --- a/docs/contracts/Host-v1契约.md +++ b/docs/contracts/Host-v1契约.md @@ -8,8 +8,8 @@ | 命令 | 参数 / 返回 | | --- | --- | -| `host_capabilities` | protocol=1,workspace/core=true;sync/credentials/extensions=false | -| `core_request` | 仅代理固定的 `http://127.0.0.1:8000/health` 与 `/api/*` JSON 请求;路径、方法和超时由 Host 限制 | +| `host_capabilities` | protocol=1,workspace/credentials=true;core 随进程状态返回;sync/extensions=false | +| `core_request` | 代理认证 Sidecar 的 `/health` 与 `/api/*`;Host 注入会话和代际,支持 JSON 与限额二进制,不接受前端鉴权头 | | `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name | | `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 | | `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 | @@ -35,6 +35,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau ## 前端与发布 -Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;当前预览版通过 Host 连接已在 `127.0.0.1:8000` 启动的 AI Core,连接失败时返回 CORE_UNAVAILABLE。随机端口、临时令牌和自动管理进程仍属于后续 Sidecar 交付。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page`、`feature-header`、`panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。 +Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;Host 自动启动受认证的 Core,使用随机端口和管道交换会话;Core 不可用时返回稳定错误码。SSE 使用 `core_stream`/`core_stream_cancel`,凭据命令提供状态、解锁、手动锁定、改密及原生导入;细节与未完成项见[生产化实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page`、`feature-header`、`panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。 -`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。 +默认 `bundle.active=false`,新增 `tauri.bundle.conf.json` 配置 Core 资源及 NSIS 目标,尚无自动更新或签名安装包。release Host 前必须执行 `uv run --directory backend --group packaging python ../scripts/build-core.py` 生成并嵌入 Core 摘要清单。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md new file mode 100644 index 0000000..012ae10 --- /dev/null +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -0,0 +1,36 @@ +# OpenNexus 生产化实施进度 + +工作树:`G:\OSProject\NotesAgent-phase3`,分支:`feat/phase3-completion`。用户已授权实施,正式名称为 OpenNexus。保留旧应用 identifier、内部包名和数据目录约定,避免更名导致旧数据不可见。个人 Vault 的已有改动不属于本次交付。 + +**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 A–E 的完整验收。 + +## 已实现的链路 + +- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。 +- 常规 JSON/二进制及 SSE 请求经原生命令进入 Core。SSE 支持游标、跨 UTF-8 分片及取消;每次流累计不超过 64 MiB,最多 16 个并发流。二进制上传暂为 64 MiB,尚未满足原媒体页面 128 MiB 的完整能力。 +- Core 可由 PyInstaller 构建为带运行时的 onedir。构建脚本生成 SHA-256 清单;release Host 编译时嵌入清单,启动前验证文件集合与摘要,拒绝链接、额外文件和篡改。新增 NSIS 资源配置,但尚未生成或验证签名安装包。 +- Stronghold 采用 Argon2id 口令派生和单文件快照;解锁期间 Store 中仍保存 AEAD 密文。提供状态、解锁、手动锁定、改密和原生选择旧库导入命令,不提供 WebView 通用明文读取命令。 +- Fernet 迁移先验证所有条目与冲突,备份密文和封装主密钥,持久化新库后重新打开逐条比较,最后切换所有权标记。旧文件保留,旧 Python 凭据 API 检测标记后拒绝读写。Core 凭据解析经 Host 管道,不在桌面 Core 使用环境变量凭据回退。 +- 同步服务按已确认 offset 修复磁盘超前,磁盘不足时拒绝续传;对象上传与校验使用流;下载先验证完整摘要再响应。完成回执支持重复确认且不重复计费。过期上传每分钟清理,CLI 提供独立清理入口;启动入口支持共享 staging 的两个 worker。ready 检查数据库、staging 和对象存储,429 带 Retry-After。 + +## 已执行的检查 + +- 后端全量复跑 894 项测试通过,测试数据使用隔离目录。 +- 前端现有 91 个文件、495 项测试通过;新增流式通道测试后,针对性 4 个文件、8 项测试通过;前端类型检查和生产构建通过。构建仍有部分既有大分块警告。 +- Rust 15 项库测试、Core 清单完整性测试通过;两个真实 Python Core 集成测试通过,其中一个完整执行 HTTP 凭据写入、管道分发和 Stronghold 解析,确认响应与 Core 目录不泄露秘密。 +- Rust desktop 全目标 Clippy `-D warnings` 通过;工具链为 Windows GNU 1.98.1,不能作为 MSVC 发布证据。 +- 同步服务 23 项隔离 SQLite 测试通过,包含 100 次重复 complete。不是 PostgreSQL/MinIO 双 worker 压测。 + +## 尚未完成的工程和验收 + +| 范围 | 尚需完成 | +| --- | --- | +| Sidecar | AI 笔记读写接入当前 Rust Vault;完整路由 DTO 与取消提交确认;恢复诊断、更新事务;128 MiB 媒体传输;签名包、干净 VM 和 20 次冷启动 | +| 凭据 | OS 锁屏联动;备份恢复与用户确认清除旧库;迁移所有边界故障注入;按真实扩展身份绑定域许可 | +| OS 沙箱 | 平台隔离实现、Host broker、资源预算与恶意二进制验证;当前未启用无沙箱第三方进程 | +| Rust 扩展管理 | 签名验证、ZIP 安全边界、持久安装/升级/回滚事务、旧包迁移、撤回与运行时生命周期 | +| 同步客户端 | Rust push/pull、持久 cursor/inbox、附件续传、绑定隔离、冲突保留与解决 UI、默认/可选数据分类 | +| 同步生产服务 | 真 PostgreSQL/MinIO 两 worker 竞争与负载验证;部署、最小权限、备份恢复、指标与运行手册 | +| 发布 | 统一逐 ID 验收 runner;MSVC 签名安装更新包、SBOM/依赖扫描;两台独立设备 E-01–05 | + +Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写入口在 desktop 模式明确拒绝,不能据此宣称 AI 笔记工作流完成。Host `sync/extensions` 能力仍为 false。缺少实机与签名环境不构成其余普通工程尚未完成的理由。 diff --git a/frontend/index.html b/frontend/index.html index a43eb97..5d39a5e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - NotesAgent + OpenNexus
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 054415f..a3f004e 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -8,6 +8,47 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -44,6 +85,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -59,6 +106,24 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ashpd" version = "0.11.1" @@ -269,6 +334,18 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -287,6 +364,21 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -317,6 +409,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq 0.4.2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -326,6 +437,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -472,6 +592,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -521,6 +650,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -532,6 +672,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -544,6 +697,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "combine" version = "4.6.8" @@ -554,6 +718,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "command-group" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" +dependencies = [ + "nix 0.27.1", + "winapi", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -563,6 +737,24 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.2" @@ -655,6 +847,24 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -662,6 +872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -704,6 +915,42 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -738,6 +985,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "dbus" version = "0.9.12" @@ -780,6 +1033,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -817,7 +1080,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -826,7 +1100,28 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -837,10 +1132,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -968,6 +1274,62 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1089,16 +1451,59 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fernet" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66b725fe9483b9ee72ccaec072b15eb8ad95a3ae63a8c798d5748883b72fd33" +dependencies = [ + "aes", + "base64 0.22.1", + "byteorder", + "cbc", + "getrandom 0.2.17", + "hmac", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1365,6 +1770,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1406,6 +1812,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1502,6 +1918,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1569,6 +1996,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1608,6 +2046,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1892,12 +2348,80 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "iota-crypto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a38db844c910d78825e173c083f2ef416b69cb091bba8ac1055763c6db065b" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "autocfg", + "base64 0.21.7", + "blake2", + "chacha20poly1305", + "cipher", + "curve25519-dalek", + "digest", + "ed25519-zebra", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "hmac", + "iterator-sorted", + "k256", + "pbkdf2", + "rand 0.8.8", + "scrypt", + "serde", + "sha2", + "tiny-keccak", + "unicode-normalization", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "iota_stronghold" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0d301c7edbc31494d183b7d24c1bb51d3fb10fce2f3793df1baf45b6988e10" +dependencies = [ + "bincode", + "hkdf", + "iota-crypto", + "rust-argon2", + "serde", + "stronghold-derive", + "stronghold-utils", + "stronghold_engine", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "ipnet" version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "iterator-sorted" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d101775d2bc8f99f4ac18bf29b9ed70c0dd138b9a1e88d7b80179470cbbe8bd2" + [[package]] name = "itoa" version = "1.0.18" @@ -2057,6 +2581,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2107,6 +2644,30 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libflate" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561a8da1a50e1428d3c51321dafeca849df992a5bb67720c386131234caba82e" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2136,6 +2697,23 @@ dependencies = [ "libc", ] +[[package]] +name = "libsodium-sys-stable" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b04bf6da2c98b727af37ab62cb505f4d751b975b034a9b9ad491d333b0564e" +dependencies = [ + "cc", + "libc", + "libflate", + "minisign-verify", + "pkg-config", + "tar", + "ureq", + "vcpkg", + "zip", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2197,6 +2775,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2212,6 +2799,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2294,12 +2887,51 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "notesagent-desktop" version = "0.3.0-alpha.1" dependencies = [ + "argon2", "base64 0.22.1", + "chacha20poly1305", + "command-group", + "fernet", "fs2", + "hmac", + "iota_stronghold", + "rand 0.8.8", "reqwest 0.12.28", "rfd", "rusqlite", @@ -2310,6 +2942,7 @@ dependencies = [ "tauri-build", "tempfile", "uuid", + "zeroize", ] [[package]] @@ -2550,6 +3183,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -2620,6 +3259,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2696,6 +3362,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2761,6 +3437,29 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2963,13 +3662,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2979,11 +3689,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2994,6 +3714,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -3033,6 +3762,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3165,6 +3905,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.15.4" @@ -3203,6 +3953,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rusqlite" version = "0.32.1" @@ -3217,6 +3973,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-argon2" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50162d19404029c1ceca6f6980fe40d45c8b369f6f44446fa14bb39573b5bb9" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq 0.1.5", + "crossbeam-utils", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3292,6 +4060,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3364,6 +4141,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3591,6 +4393,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3673,6 +4485,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3703,6 +4525,64 @@ dependencies = [ "quote", ] +[[package]] +name = "stronghold-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2835db23c4724c05a2f85b81c4681f4aa8ea158edc8a7f4ad791c916fb766c2e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "stronghold-runtime" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18db7cc51450cefdab5f4990e128dd02c98da6d2992b93ffef8992ac0d2f3ddf" +dependencies = [ + "dirs 4.0.0", + "iota-crypto", + "libc", + "libsodium-sys-stable", + "log", + "nix 0.24.3", + "rand 0.8.8", + "serde", + "thiserror 1.0.69", + "windows 0.36.1", + "zeroize", +] + +[[package]] +name = "stronghold-utils" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8300214898af5e153e7f66e49dbd1c6a21585f2d592d9f24f58b969792475ed6" +dependencies = [ + "rand 0.8.8", + "stronghold-derive", +] + +[[package]] +name = "stronghold_engine" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd7371c42e557dd71a7f860bb2ec6b6fdb32f97a97987ccc2435fdd1f3a8615" +dependencies = [ + "anyhow", + "dirs-next", + "hex", + "iota-crypto", + "once_cell", + "paste", + "serde", + "stronghold-runtime", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3733,6 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -3825,7 +4706,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3842,6 +4723,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3857,7 +4749,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3896,7 +4788,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3907,7 +4799,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3983,7 +4875,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4008,7 +4900,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4153,6 +5045,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -4418,7 +5319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4439,6 +5340,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -4457,7 +5364,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -4509,18 +5416,62 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4552,6 +5503,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4863,7 +5820,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4887,7 +5844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4937,6 +5894,19 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53b97a83176b369b0eb2fd8158d4ae215357d02df9d40c1e1bf1879c5482c80" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + [[package]] name = "windows" version = "0.61.3" @@ -5173,6 +6143,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5185,6 +6161,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5203,6 +6185,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5215,6 +6203,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5239,6 +6233,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5307,7 +6307,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5335,7 +6335,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5362,6 +6362,27 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5501,6 +6522,21 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -5535,6 +6571,20 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -5547,6 +6597,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index ed27245..5e9aaa7 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,6 +28,19 @@ 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 } base64 = { version = "0.22", optional = true } +hmac = { version = "0.12", default-features = false } +rand = { version = "0.8", default-features = false, features = ["getrandom"] } +zeroize = { version = "1", default-features = false, features = ["alloc"] } +command-group = { version = "5", default-features = false } +iota_stronghold = "2.1" +argon2 = "0.5" +chacha20poly1305 = "0.10" +fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] } [build-dependencies] tauri-build = { version = "2", optional = true , features = [] } + +# Cryptographic KDFs retain their production work factors in debug/test runs. +# Optimize dependencies rather than weakening those factors for local execution. +[profile.dev.package."*"] +opt-level = 2 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 6758303..9f35d0c 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -1,9 +1,29 @@ fn main() { + let manifest = std::path::Path::new("../../.build/sidecar/manifest.json"); + println!("cargo:rerun-if-changed={}", manifest.display()); + let content = if std::env::var("PROFILE").as_deref() == Ok("release") { + std::fs::read(manifest) + .expect("Build Core with scripts/build-core.py before a release Host") + } else { + b"{}".to_vec() + }; + std::fs::write( + std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("core-manifest.json"), + content, + ) + .unwrap(); #[cfg(feature = "desktop")] tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&[ "host_capabilities", + "credentials_status", + "credentials_unlock", + "credentials_lock", + "credentials_change_password", + "credentials_import", "core_request", + "core_stream", + "core_stream_cancel", "editor_capabilities", "workspace_choose", "workspace_open", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index c50da85..ac5ef36 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -8,7 +8,14 @@ "permissions": [ "core:default", "allow-host-capabilities", + "allow-credentials-status", + "allow-credentials-unlock", + "allow-credentials-lock", + "allow-credentials-change-password", + "allow-credentials-import", "allow-core-request", + "allow-core-stream", + "allow-core-stream-cancel", "allow-workspace-choose", "allow-workspace-open", "allow-workspace-tree", diff --git a/frontend/src-tauri/permissions/autogenerated/core_stream.toml b/frontend/src-tauri/permissions/autogenerated/core_stream.toml new file mode 100644 index 0000000..ced0989 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/core_stream.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-core-stream" +description = "Enables the core_stream command without any pre-configured scope." +commands.allow = ["core_stream"] + +[[permission]] +identifier = "deny-core-stream" +description = "Denies the core_stream command without any pre-configured scope." +commands.deny = ["core_stream"] diff --git a/frontend/src-tauri/permissions/autogenerated/core_stream_cancel.toml b/frontend/src-tauri/permissions/autogenerated/core_stream_cancel.toml new file mode 100644 index 0000000..256edce --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/core_stream_cancel.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-core-stream-cancel" +description = "Enables the core_stream_cancel command without any pre-configured scope." +commands.allow = ["core_stream_cancel"] + +[[permission]] +identifier = "deny-core-stream-cancel" +description = "Denies the core_stream_cancel command without any pre-configured scope." +commands.deny = ["core_stream_cancel"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_change_password.toml b/frontend/src-tauri/permissions/autogenerated/credentials_change_password.toml new file mode 100644 index 0000000..eba6786 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_change_password.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-credentials-change-password" +description = "Enables the credentials_change_password command without any pre-configured scope." +commands.allow = ["credentials_change_password"] + +[[permission]] +identifier = "deny-credentials-change-password" +description = "Denies the credentials_change_password command without any pre-configured scope." +commands.deny = ["credentials_change_password"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_import.toml b/frontend/src-tauri/permissions/autogenerated/credentials_import.toml new file mode 100644 index 0000000..a879c0b --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_import.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-credentials-import" +description = "Enables the credentials_import command without any pre-configured scope." +commands.allow = ["credentials_import"] + +[[permission]] +identifier = "deny-credentials-import" +description = "Denies the credentials_import command without any pre-configured scope." +commands.deny = ["credentials_import"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_lock.toml b/frontend/src-tauri/permissions/autogenerated/credentials_lock.toml new file mode 100644 index 0000000..ec4b72a --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_lock.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-credentials-lock" +description = "Enables the credentials_lock command without any pre-configured scope." +commands.allow = ["credentials_lock"] + +[[permission]] +identifier = "deny-credentials-lock" +description = "Denies the credentials_lock command without any pre-configured scope." +commands.deny = ["credentials_lock"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_status.toml b/frontend/src-tauri/permissions/autogenerated/credentials_status.toml new file mode 100644 index 0000000..830cc68 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-credentials-status" +description = "Enables the credentials_status command without any pre-configured scope." +commands.allow = ["credentials_status"] + +[[permission]] +identifier = "deny-credentials-status" +description = "Denies the credentials_status command without any pre-configured scope." +commands.deny = ["credentials_status"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_unlock.toml b/frontend/src-tauri/permissions/autogenerated/credentials_unlock.toml new file mode 100644 index 0000000..c46de9e --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_unlock.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-credentials-unlock" +description = "Enables the credentials_unlock command without any pre-configured scope." +commands.allow = ["credentials_unlock"] + +[[permission]] +identifier = "deny-credentials-unlock" +description = "Denies the credentials_unlock command without any pre-configured scope." +commands.deny = ["credentials_unlock"] diff --git a/frontend/src-tauri/src/core.rs b/frontend/src-tauri/src/core.rs new file mode 100644 index 0000000..2039155 --- /dev/null +++ b/frontend/src-tauri/src/core.rs @@ -0,0 +1,471 @@ +//! Trusted Core process supervisor. The WebView never receives session material. +use command_group::{CommandGroup, GroupChild}; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::Sha256; +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{ChildStdin, Command, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; +use zeroize::Zeroizing; + +type Result = std::result::Result; +pub type Broker = Arc Result + Send + Sync>; + +/// The manifest is embedded in the Host at build time, never loaded from the installation. +pub fn verify_bundle(root: &Path, manifest: &str) -> Result<()> { + use sha2::Digest; + use std::collections::BTreeMap; + #[derive(Deserialize)] + struct Manifest { + protocol: u32, + product: String, + files: BTreeMap, + } + let expected: Manifest = serde_json::from_str(manifest).map_err(|_| "CORE_MANIFEST_INVALID")?; + if expected.protocol != 1 || expected.product != "OpenNexus" || expected.files.is_empty() { + return Err("CORE_MANIFEST_INVALID".into()); + } + fn inventory( + root: &Path, + directory: &Path, + files: &mut BTreeMap, + ) -> Result<()> { + let metadata = std::fs::symlink_metadata(directory).map_err(|_| "CORE_INTEGRITY_FAILED")?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("CORE_INTEGRITY_FAILED".into()); + } + } + if metadata.file_type().is_symlink() { + return Err("CORE_INTEGRITY_FAILED".into()); + } + if metadata.is_dir() { + for entry in std::fs::read_dir(directory).map_err(|_| "CORE_INTEGRITY_FAILED")? { + inventory( + root, + &entry.map_err(|_| "CORE_INTEGRITY_FAILED")?.path(), + files, + )?; + } + } else if metadata.is_file() { + let mut file = std::fs::File::open(directory).map_err(|_| "CORE_INTEGRITY_FAILED")?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = file + .read(&mut buffer) + .map_err(|_| "CORE_INTEGRITY_FAILED")?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + let name = directory + .strip_prefix(root) + .map_err(|_| "CORE_INTEGRITY_FAILED")? + .to_str() + .ok_or("CORE_INTEGRITY_FAILED")? + .replace('\\', "/"); + files.insert(name, format!("{:x}", hash.finalize())); + } else { + return Err("CORE_INTEGRITY_FAILED".into()); + } + Ok(()) + } + let mut actual = BTreeMap::new(); + inventory(root, root, &mut actual)?; + if actual != expected.files { + return Err("CORE_INTEGRITY_FAILED".into()); + } + Ok(()) +} + +fn random_hex() -> Result { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| "CORE_ENTROPY_UNAVAILABLE")?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn decode_hex(value: &str) -> Result> { + if value.len() != 64 || !value.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err("CORE_HANDSHAKE_INVALID".into()); + } + (0..64) + .step_by(2) + .map(|i| { + u8::from_str_radix(&value[i..i + 2], 16).map_err(|_| "CORE_HANDSHAKE_INVALID".into()) + }) + .collect() +} + +#[derive(Deserialize)] +struct Ready { + protocol: u32, + pid: u32, + launcher_pid: u32, + port: u16, + generation: String, + proof: String, +} + +fn verify_ready( + line: &[u8], + secret: &str, + challenge: &str, + generation: &str, + pid: u32, +) -> Result { + let ready: Ready = serde_json::from_slice(line).map_err(|_| "CORE_HANDSHAKE_INVALID")?; + if ready.protocol != 1 { + return Err("PROTOCOL_INCOMPATIBLE".into()); + } + if ready.launcher_pid != pid + || ready.pid == 0 + || ready.port == 0 + || ready.generation != generation + { + return Err("CORE_HANDSHAKE_INVALID".into()); + } + let mut mac = Hmac::::new_from_slice(&decode_hex(secret)?) + .map_err(|_| "CORE_HANDSHAKE_INVALID")?; + mac.update( + format!( + "1:{challenge}:{generation}:{pid}:{}:{}", + ready.pid, ready.port + ) + .as_bytes(), + ); + mac.verify_slice(&decode_hex(&ready.proof)?) + .map_err(|_| "CORE_HANDSHAKE_INVALID")?; + Ok(ready.port) +} + +pub fn checked_url(port: u16, path: &str) -> Result { + let resource = path.split('?').next().unwrap_or(""); + if !(resource.starts_with("/api/") || resource == "/api" || resource == "/health") + || path.contains(['\\', '\r', '\n', '#']) + || resource.contains('%') + || resource.split('/').any(|part| part == "." || part == "..") + || resource.contains("//") + || path.len() > 8192 + { + return Err("CORE_PATH_DENIED".into()); + } + Ok(format!("http://127.0.0.1:{port}{path}")) +} + +pub struct Session { + child: GroupChild, + lifetime: Arc>>, + secret: Zeroizing, + generation: String, + port: u16, +} + +impl Drop for Session { + fn drop(&mut self) { + if let Ok(mut pipe) = self.lifetime.lock() { + pipe.take(); + } + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if self.child.try_wait().ok().flatten().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + // Kill the entire group even if its leader has exited. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub struct CoreSupervisor { + executable: PathBuf, + arguments: Vec, + working_dir: PathBuf, + data_dir: PathBuf, + session: Option, + attempts: VecDeque, + next_attempt: Option, + broker: Option, + bundle_manifest: Option, +} + +/// Host-only request context; deliberately neither Serialize nor Debug. +pub struct RequestSession { + pub url: String, + pub authorization: Zeroizing, + pub generation: String, +} + +impl CoreSupervisor { + pub fn new( + executable: PathBuf, + arguments: Vec, + working_dir: PathBuf, + data_dir: PathBuf, + ) -> Self { + Self { + executable, + arguments, + working_dir, + data_dir, + session: None, + attempts: VecDeque::new(), + next_attempt: None, + broker: None, + bundle_manifest: None, + } + } + + pub fn with_broker(mut self, broker: Broker) -> Self { + self.broker = Some(broker); + self + } + + pub fn with_bundle_manifest(mut self, manifest: String) -> Self { + self.bundle_manifest = Some(manifest); + self + } + + pub fn available(&mut self) -> bool { + self.session + .as_mut() + .is_some_and(|s| matches!(s.child.try_wait(), Ok(None))) + } + + pub fn request_session(&mut self, path: &str) -> Result { + checked_url(1, path)?; + if !self.available() { + self.start()?; + } + let session = self.session.as_ref().ok_or("CORE_UNAVAILABLE")?; + Ok(RequestSession { + url: checked_url(session.port, path)?, + authorization: Zeroizing::new(format!("Bearer {}", session.secret.as_str())), + generation: session.generation.clone(), + }) + } + + pub fn start(&mut self) -> Result<()> { + 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 { + 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)?; + } + let session = Self::spawn( + &self.executable, + &self.arguments, + &self.working_dir, + &self.data_dir, + self.broker.clone(), + )?; + self.session = Some(session); + Ok(()) + } + + fn spawn( + executable: &Path, + args: &[String], + working_dir: &Path, + data_dir: &Path, + broker: Option, + ) -> Result { + let secret = Zeroizing::new(random_hex()?); + let generation = random_hex()?; + let challenge = random_hex()?; + let mut command = Command::new(executable); + command + .args(args) + .current_dir(working_dir) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // Runtime requirements only; never copy Provider tokens or general PATH. + for key in [ + "SystemRoot", + "WINDIR", + "TEMP", + "TMP", + "LANG", + "HOME", + "USERPROFILE", + "LOCALAPPDATA", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command.env("PYTHONUTF8", "1").env("PYTHONUNBUFFERED", "1"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let child = command.group_spawn().map_err(|_| "CORE_SPAWN_FAILED")?; + let mut session = Session { + child, + lifetime: Arc::new(Mutex::new(None)), + secret, + generation, + port: 0, + }; + let stdout = session + .child + .inner() + .stdout + .take() + .ok_or("CORE_PIPE_FAILED")?; + *session.lifetime.lock().map_err(|_| "CORE_PIPE_FAILED")? = + session.child.inner().stdin.take(); + let mut payload = Zeroizing::new( + serde_json::to_vec(&serde_json::json!({ + "protocol": 1, "launcher_pid": session.child.id(), "secret": session.secret.as_str(), "challenge": challenge, + "generation": session.generation, "data_dir": data_dir, + })) + .map_err(|_| "CORE_BOOTSTRAP_INVALID")?, + ); + payload.push(b'\n'); + session + .lifetime + .lock() + .map_err(|_| "CORE_PIPE_FAILED")? + .as_mut() + .ok_or("CORE_PIPE_FAILED")? + .write_all(&payload) + .map_err(|_| "CORE_PIPE_FAILED")?; + let (tx, rx) = mpsc::channel(); + let lifetime = session.lifetime.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = Zeroizing::new(Vec::new()); + match reader.by_ref().take(131073).read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + _ => {} + } + if line.len() > 131072 { + break; + } + let Ok(message) = serde_json::from_slice::(&line) else { + break; + }; + if message.get("rpc").is_none() { + let _ = tx.send(Ok::, std::io::Error>(line.to_vec())); + continue; + } + let request_id = message + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let result = broker + .as_ref() + .ok_or_else(|| "HOST_BROKER_UNAVAILABLE".to_string()) + .and_then(|b| b(&message)); + let response = match result { + Ok(result) => serde_json::json!({"request_id":request_id,"result":result}), + Err(error) => serde_json::json!({"request_id":request_id,"error":error}), + }; + let Ok(bytes) = serde_json::to_vec(&response) else { + break; + }; + let mut bytes = Zeroizing::new(bytes); + if bytes.len() > 131072 { + break; + } + bytes.push(b'\n'); + let Ok(mut pipe) = lifetime.lock() else { + break; + }; + let Some(pipe) = pipe.as_mut() else { + break; + }; + if pipe.write_all(&bytes).is_err() { + break; + } + } + }); + let line = rx + .recv_timeout(Duration::from_secs(30)) + .map_err(|_| "CORE_READY_TIMEOUT")? + .map_err(|_| "CORE_HANDSHAKE_INVALID")?; + if line.len() > 16384 { + return Err("CORE_HANDSHAKE_INVALID".into()); + } + session.port = verify_ready( + &line, + &session.secret, + &challenge, + &session.generation, + session.child.id(), + )?; + Ok(session) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn paths_cannot_redirect_or_escape() { + for path in [ + "https://evil/api", + "/api/../x", + "/api/%2e%2e/x", + "/api//x", + "/api/a\\b", + "/api/a#x", + "/api/a\r\nHost:x", + ] { + assert!(checked_url(4321, path).is_err(), "{path}"); + } + assert_eq!( + checked_url(4321, "/api/search?q=%E4%B8%AD").unwrap(), + "http://127.0.0.1:4321/api/search?q=%E4%B8%AD" + ); + } + #[test] + fn proof_has_python_compatible_framing_and_binds_identity() { + let secret = "01".repeat(32); + let challenge = "02".repeat(32); + let generation = "03".repeat(32); + let mut mac = Hmac::::new_from_slice(&[1; 32]).unwrap(); + mac.update(format!("1:{challenge}:{generation}:123:123:4567").as_bytes()); + let signature: String = mac + .finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let payload = serde_json::to_vec(&serde_json::json!({"protocol":1,"launcher_pid":123,"pid":123,"port":4567,"generation":generation,"proof":signature})).unwrap(); + assert_eq!( + verify_ready(&payload, &secret, &challenge, &generation, 123).unwrap(), + 4567 + ); + assert!(verify_ready(&payload, &secret, &challenge, &generation, 124).is_err()); + assert!(verify_ready(&payload, &secret, &"04".repeat(32), &generation, 123).is_err()); + } +} diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs new file mode 100644 index 0000000..2d9be30 --- /dev/null +++ b/frontend/src-tauri/src/credentials.rs @@ -0,0 +1,710 @@ +//! Device-local Stronghold broker. No public IPC returns secret bytes. +//! +//! Stronghold Store contains AEAD ciphertext, including while unlocked. Snapshot +//! and salt are one atomic envelope, so password changes cannot tear two files. +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, Payload}, + ChaCha20Poly1305, KeyInit, Nonce, +}; +use iota_stronghold::{KeyProvider, SnapshotPath, Stronghold}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use zeroize::Zeroizing; + +type Result = std::result::Result; +const CLIENT: &[u8] = b"opennexus.credentials.v1"; +const MAGIC: &[u8] = b"ONXCRED1"; +const MAX_FILE: u64 = 16 * 1024 * 1024; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "owner", rename_all = "snake_case")] +pub enum Scope { + Provider, + Plugin(String), + Mcp(String), + Sync(String), +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CredentialId { + pub scope: Scope, + pub id: String, +} + +impl CredentialId { + /// Preserve opaque legacy references. Hashed Plugin/MCP IDs remain isolated + /// from Provider IDs; only the trusted Core adapter can use these aliases. + pub fn legacy(id: &str) -> Self { + let scope = if let Some(owner) = id.strip_prefix("plugin.") { + Scope::Plugin(owner.into()) + } else if let Some(owner) = id.strip_prefix("mcp.") { + Scope::Mcp(owner.into()) + } else { + Scope::Provider + }; + Self { + scope, + id: id.into(), + } + } + fn key(&self) -> Result> { + fn valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + } + if !valid(&self.id) { + return Err("CREDENTIAL_ID_INVALID".into()); + } + match &self.scope { + Scope::Provider + if self.id.to_lowercase().starts_with("plugin.") + || self.id.to_lowercase().starts_with("mcp.") => + { + return Err("CREDENTIAL_SCOPE_DENIED".into()) + } + Scope::Plugin(owner) | Scope::Mcp(owner) | Scope::Sync(owner) if !valid(owner) => { + return Err("CREDENTIAL_SCOPE_DENIED".into()) + } + _ => {} + } + serde_json::to_vec(self).map_err(|_| "CREDENTIAL_ID_INVALID".into()) + } +} + +struct Unlocked { + stronghold: Stronghold, + key: Zeroizing>, + salt: [u8; 32], +} + +impl Drop for Unlocked { + fn drop(&mut self) { + let _ = self.stronghold.clear(); + } +} + +impl Unlocked { + fn derive(password: &[u8], salt: [u8; 32]) -> Result { + if password.len() < 12 || password.len() > 1024 { + return Err("CREDENTIAL_PASSWORD_LENGTH".into()); + } + let params = Params::new(65536, 3, 1, Some(32)).map_err(|_| "CREDENTIAL_KDF_FAILED")?; + let mut key = Zeroizing::new(vec![0; 32]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password, &salt, &mut key) + .map_err(|_| "CREDENTIAL_KDF_FAILED")?; + Ok(Self { + stronghold: Stronghold::default(), + key, + salt, + }) + } + fn cipher(&self) -> Result { + let mut hash = Sha256::new(); + hash.update(self.key.as_slice()); + hash.update(b"opennexus.credential-record.v1"); + let key = Zeroizing::new(hash.finalize().to_vec()); + ChaCha20Poly1305::new_from_slice(&key).map_err(|_| "CREDENTIAL_CIPHER_FAILED".into()) + } + fn provider(&self) -> Result { + KeyProvider::try_from(Zeroizing::new(self.key.to_vec())) + .map_err(|_| "CREDENTIAL_KDF_FAILED".into()) + } + fn store(&self) -> Result { + self.stronghold + .get_client(CLIENT) + .map(|c| c.store()) + .map_err(|_| "CREDENTIAL_STORE_FAILED".into()) + } + fn read(&self, key: &[u8]) -> Result>>> { + let Some(data) = self + .store()? + .get(key) + .map_err(|_| "CREDENTIAL_STORE_FAILED")? + else { + return Ok(None); + }; + if data.len() < 28 { + return Err("CREDENTIAL_STORE_CORRUPT".into()); + } + self.cipher()? + .decrypt( + Nonce::from_slice(&data[..12]), + Payload { + msg: &data[12..], + aad: key, + }, + ) + .map(Zeroizing::new) + .map(Some) + .map_err(|_| "CREDENTIAL_STORE_CORRUPT".into()) + } + fn write(&self, key: Vec, value: &[u8]) -> Result<()> { + if value.is_empty() || value.len() > 65536 { + return Err("CREDENTIAL_VALUE_INVALID".into()); + } + let mut nonce = [0u8; 12]; + rand::rngs::OsRng + .try_fill_bytes(&mut nonce) + .map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?; + let ciphertext = self + .cipher()? + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: value, + aad: &key, + }, + ) + .map_err(|_| "CREDENTIAL_CIPHER_FAILED")?; + let mut record = nonce.to_vec(); + record.extend(ciphertext); + self.store()? + .insert(key, record, None) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + Ok(()) + } + fn persist(&self, path: &Path) -> Result<()> { + let parent = path.parent().ok_or("CREDENTIAL_PATH_INVALID")?; + fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?; + let staging = tempfile::tempdir_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?; + let snapshot = staging.path().join("snapshot"); + self.stronghold + .write_client(CLIENT) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + self.stronghold + .commit_with_keyprovider(&SnapshotPath::from_path(&snapshot), &self.provider()?) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + let bytes = fs::read(&snapshot).map_err(|_| "CREDENTIAL_IO_FAILED")?; + let mut target = + tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?; + target + .write_all(MAGIC) + .and_then(|_| target.write_all(&self.salt)) + .and_then(|_| target.write_all(&bytes)) + .and_then(|_| target.as_file().sync_all()) + .map_err(|_| "CREDENTIAL_IO_FAILED")?; + target.persist(path).map_err(|_| "CREDENTIAL_IO_FAILED")?; + Ok(()) + } +} + +pub struct CredentialBroker { + path: PathBuf, + unlocked: Option, +} + +impl CredentialBroker { + /// Source comes from the native file picker, never a raw WebView path. + /// Import is idempotent; conflicting IDs stop the entire transaction. + pub fn import_fernet( + &mut self, + directory: &Path, + environment_key: Option>, + ) -> Result { + use fs2::FileExt; + let directory = directory + .canonicalize() + .map_err(|_| "MIGRATION_SOURCE_INVALID")?; + let source = directory.join("credentials.json"); + let key_path = directory.join("master.key"); + if !fs::symlink_metadata(&source) + .map_err(|_| "MIGRATION_SOURCE_INVALID")? + .is_file() + { + return Err("MIGRATION_SOURCE_INVALID".into()); + } + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(directory.join(".migration.lock")) + .map_err(|_| "MIGRATION_SOURCE_BUSY")?; + lock.try_lock_exclusive() + .map_err(|_| "MIGRATION_SOURCE_BUSY")?; + if fs::metadata(&source) + .map_err(|_| "MIGRATION_SOURCE_INVALID")? + .len() + > MAX_FILE + { + return Err("MIGRATION_SOURCE_INVALID".into()); + } + let source_bytes = fs::read(&source).map_err(|_| "MIGRATION_SOURCE_INVALID")?; + let source_hash = format!("{:x}", Sha256::digest(&source_bytes)); + let tokens: BTreeMap = + serde_json::from_slice(&source_bytes).map_err(|_| "MIGRATION_SOURCE_INVALID")?; + if tokens.len() > 10000 { + return Err("MIGRATION_SOURCE_INVALID".into()); + } + let local_key = if environment_key.is_none() { + if !fs::symlink_metadata(&key_path) + .map_err(|_| "MIGRATION_KEY_MISSING")? + .is_file() + { + return Err("MIGRATION_KEY_MISSING".into()); + } + Some(Zeroizing::new( + fs::read_to_string(&key_path).map_err(|_| "MIGRATION_KEY_MISSING")?, + )) + } else { + None + }; + let key = environment_key + .as_ref() + .or(local_key.as_ref()) + .ok_or("MIGRATION_KEY_MISSING")?; + let fernet = + Zeroizing::new(fernet::Fernet::new(key.trim()).ok_or("MIGRATION_KEY_INVALID")?); + let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?; + let mut decoded = Vec::new(); + for (id, token) in &tokens { + let key = CredentialId::legacy(id).key()?; + let value = Zeroizing::new( + fernet + .decrypt(token) + .map_err(|_| "MIGRATION_DECRYPT_FAILED")?, + ); + if value.is_empty() || value.len() > 65536 || std::str::from_utf8(&value).is_err() { + return Err("MIGRATION_VALUE_INVALID".into()); + } + if let Some(existing) = session.read(&key)? { + if existing.as_slice() != value.as_slice() { + return Err("MIGRATION_CONFLICT".into()); + } + } + decoded.push((key, value)); + } + let migration_id = format!( + "{:x}", + Sha256::digest(directory.to_string_lossy().as_bytes()) + ); + let backup = self + .path + .parent() + .ok_or("CREDENTIAL_PATH_INVALID")? + .join("migration-backups") + .join(&migration_id); + fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?; + // Backups contain ciphertext; the legacy key is sealed under the already + // unlocked device key, rather than adding another plaintext master.key. + let mut nonce = [0u8; 12]; + rand::rngs::OsRng + .try_fill_bytes(&mut nonce) + .map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?; + let sealed_key = session + .cipher()? + .encrypt(Nonce::from_slice(&nonce), key.as_bytes()) + .map_err(|_| "MIGRATION_BACKUP_FAILED")?; + let mut key_backup = b"ONXFBK1".to_vec(); + key_backup.extend(session.salt); + key_backup.extend(nonce); + key_backup.extend(sealed_key); + for (name, bytes) in [ + ("credentials.json", source_bytes.as_slice()), + ("master-key.sealed", key_backup.as_slice()), + ] { + let mut temporary = + tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?; + temporary + .write_all(bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|_| "MIGRATION_BACKUP_FAILED")?; + temporary + .persist(backup.join(name)) + .map_err(|_| "MIGRATION_BACKUP_FAILED")?; + } + let result = (|| { + for (key, value) in &decoded { + session.write(key.clone(), value)?; + } + session.persist(&self.path)?; + // Re-open the committed Stronghold snapshot, not the in-memory cache. + let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?; + let mut temporary = + tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_VERIFY_FAILED")?; + temporary + .write_all(&envelope[40..]) + .map_err(|_| "MIGRATION_VERIFY_FAILED")?; + let verified = Unlocked { + stronghold: Stronghold::default(), + key: Zeroizing::new(session.key.to_vec()), + salt: session.salt, + }; + verified + .stronghold + .load_client_from_snapshot( + CLIENT, + &verified.provider()?, + &SnapshotPath::from_path(temporary.path()), + ) + .map_err(|_| "MIGRATION_VERIFY_FAILED")?; + for (key, value) in &decoded { + if verified.read(key)?.as_deref().map(|v| v.as_slice()) != Some(value.as_slice()) { + return Err("MIGRATION_VERIFY_FAILED".into()); + } + } + if fs::read(&source).map_err(|_| "MIGRATION_SOURCE_CHANGED")? != source_bytes { + return Err("MIGRATION_SOURCE_CHANGED".into()); + } + let marker = serde_json::json!({"schema":1,"owner":"OpenNexus","state":"switched","source_sha256":source_hash,"count":decoded.len(),"environment_key":environment_key.is_some()}); + let bytes = serde_json::to_vec(&marker).map_err(|_| "MIGRATION_VERIFY_FAILED")?; + let mut marker_file = tempfile::NamedTempFile::new_in(&directory) + .map_err(|_| "MIGRATION_SWITCH_FAILED")?; + marker_file + .write_all(&bytes) + .and_then(|_| marker_file.as_file().sync_all()) + .map_err(|_| "MIGRATION_SWITCH_FAILED")?; + marker_file + .persist(directory.join(".opennexus-owner.json")) + .map_err(|_| "MIGRATION_SWITCH_FAILED")?; + Ok(decoded.len()) + })(); + if result.is_err() { + self.lock(); + } + result + } + pub fn dispatch(&mut self, request: &serde_json::Value) -> Result { + let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?; + let params = &request["params"]; + if method == "credentials.delete_many" || method == "credentials.move_many" { + let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?; + let result = (|| { + let mut removed = Vec::new(); + if method.ends_with("delete_many") { + let ids = params["ids"].as_array().ok_or("HOST_REQUEST_INVALID")?; + let keys: Vec<_> = ids + .iter() + .map(|id| { + let id = id.as_str().ok_or("HOST_REQUEST_INVALID")?; + Ok((id.to_string(), CredentialId::legacy(id).key()?)) + }) + .collect::>()?; + for (id, key) in keys { + if session + .store()? + .delete(&key) + .map_err(|_| "CREDENTIAL_STORE_FAILED")? + .is_some() + { + removed.push(id); + } + } + } else { + let replacements = params["replacements"] + .as_object() + .ok_or("HOST_REQUEST_INVALID")?; + let mut moves = Vec::new(); + for (old, new) in replacements { + let source = CredentialId::legacy(old); + let target = + CredentialId::legacy(new.as_str().ok_or("HOST_REQUEST_INVALID")?); + // ID migrations cannot change Provider/Plugin/MCP families. + if std::mem::discriminant(&source.scope) + != std::mem::discriminant(&target.scope) + { + return Err("CREDENTIAL_SCOPE_DENIED".into()); + } + let source_key = source.key()?; + let target_key = target.key()?; + if let Some(value) = session.read(&source_key)? { + if let Some(existing) = session.read(&target_key)? { + if existing.as_slice() != value.as_slice() { + return Err("MIGRATION_CONFLICT".into()); + } + } + moves.push((source_key, target_key, value)); + } + } + // Reject cycles/overlapping source+destination rather than deleting + // a newly written value midway through a multi-ID migration. + if moves.iter().any(|(old, new, _)| { + old != new && moves.iter().any(|(source, _, _)| source == new) + }) { + return Err("MIGRATION_CONFLICT".into()); + } + for (old, new, value) in moves { + session.write(new.clone(), &value)?; + if old != new { + session + .store()? + .delete(&old) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + } + } + } + session.persist(&self.path)?; + Ok(serde_json::json!(removed)) + })(); + if result.is_err() { + self.lock(); + } + return result; + } + let id = CredentialId::legacy(params["id"].as_str().ok_or("HOST_REQUEST_INVALID")?); + match method { + "credentials.resolve" => self + .resolve(&id.scope, &id)? + .map(|v| { + String::from_utf8(v.to_vec()) + .map(serde_json::Value::String) + .map_err(|_| "CREDENTIAL_ENCODING_INVALID".into()) + }) + .unwrap_or(Ok(serde_json::Value::Null)), + "credentials.has" => Ok(serde_json::json!(self.resolve(&id.scope, &id)?.is_some())), + "credentials.put" => { + let value = params["secret"].as_str().ok_or("HOST_REQUEST_INVALID")?; + self.put(&id, Zeroizing::new(value.as_bytes().to_vec()))?; + Ok(serde_json::Value::Null) + } + "credentials.delete" => { + let existed = self.resolve(&id.scope, &id)?.is_some(); + self.delete(&id)?; + Ok(serde_json::json!(existed)) + } + _ => Err("HOST_METHOD_DENIED".into()), + } + } + pub fn new(path: PathBuf) -> Self { + Self { + path, + unlocked: None, + } + } + pub fn is_locked(&self) -> bool { + self.unlocked.is_none() + } + pub fn lock(&mut self) { + self.unlocked.take(); + } + pub fn unlock(&mut self, password: Zeroizing>) -> Result<()> { + self.lock(); + let session = if self.path.exists() { + let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?; + if !metadata.is_file() || metadata.len() > MAX_FILE { + return Err("CREDENTIAL_STORE_CORRUPT".into()); + } + let data = fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?; + if data.len() < 40 || &data[..8] != MAGIC { + return Err("SCHEMA_INCOMPATIBLE".into()); + } + let mut salt = [0u8; 32]; + salt.copy_from_slice(&data[8..40]); + let session = Unlocked::derive(&password, salt)?; + let mut temp = tempfile::NamedTempFile::new_in( + self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?, + ) + .map_err(|_| "CREDENTIAL_IO_FAILED")?; + temp.write_all(&data[40..]) + .map_err(|_| "CREDENTIAL_IO_FAILED")?; + session + .stronghold + .load_client_from_snapshot( + CLIENT, + &session.provider()?, + &SnapshotPath::from_path(temp.path()), + ) + .map_err(|_| "CREDENTIAL_UNLOCK_FAILED")?; + session + } else { + let mut salt = [0u8; 32]; + rand::rngs::OsRng + .try_fill_bytes(&mut salt) + .map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?; + let session = Unlocked::derive(&password, salt)?; + session + .stronghold + .create_client(CLIENT) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + session.persist(&self.path)?; + session + }; + self.unlocked = Some(session); + Ok(()) + } + pub fn list(&self) -> Result> { + self.unlocked + .as_ref() + .ok_or("CREDENTIALS_LOCKED")? + .store()? + .keys() + .map_err(|_| "CREDENTIAL_STORE_FAILED")? + .iter() + .map(|key| serde_json::from_slice(key).map_err(|_| "CREDENTIAL_STORE_CORRUPT".into())) + .collect() + } + pub fn put(&mut self, id: &CredentialId, value: Zeroizing>) -> Result<()> { + let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?; + let result = session + .write(id.key()?, &value) + .and_then(|_| session.persist(&self.path)); + if result.is_err() { + self.lock(); + } // Never serve uncommitted memory after disk failure. + result + } + pub fn delete(&mut self, id: &CredentialId) -> Result<()> { + let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?; + session + .store()? + .delete(&id.key()?) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + let result = session.persist(&self.path); + if result.is_err() { + self.lock(); + } + result + } + /// Internal consumers must supply the scope established by the Host dispatcher. + /// This method must never be registered as a Tauri command. + pub fn resolve(&self, caller: &Scope, id: &CredentialId) -> Result>>> { + if caller != &id.scope { + return Err("CREDENTIAL_SCOPE_DENIED".into()); + } + self.unlocked + .as_ref() + .ok_or("CREDENTIALS_LOCKED")? + .read(&id.key()?) + } + pub fn change_password(&mut self, password: Zeroizing>) -> Result<()> { + let previous = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?; + let mut salt = [0u8; 32]; + rand::rngs::OsRng + .try_fill_bytes(&mut salt) + .map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?; + let next = Unlocked::derive(&password, salt)?; + next.stronghold + .create_client(CLIENT) + .map_err(|_| "CREDENTIAL_STORE_FAILED")?; + for key in previous + .store()? + .keys() + .map_err(|_| "CREDENTIAL_STORE_FAILED")? + { + let value = previous.read(&key)?.ok_or("CREDENTIAL_STORE_CORRUPT")?; + next.write(key, &value)?; + } + next.persist(&self.path)?; + self.unlocked = Some(next); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn password() -> Zeroizing> { + Zeroizing::new(b"test-only-password-123".to_vec()) + } + #[test] + fn python_fernet_migration_is_verified_idempotent_and_preserves_sources() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let old = temp.path().join("legacy"); + fs::create_dir(&old).unwrap(); + let source = serde_json::to_vec(&fixture["tokens"]).unwrap(); + fs::write(old.join("credentials.json"), &source).unwrap(); + fs::write(old.join("master.key"), fixture["key"].as_str().unwrap()).unwrap(); + let mut broker = CredentialBroker::new(temp.path().join("new/stronghold.v1")); + broker.unlock(password()).unwrap(); + assert_eq!(broker.import_fernet(&old, None).unwrap(), 100); + assert_eq!(broker.import_fernet(&old, None).unwrap(), 100); + assert_eq!(broker.list().unwrap().len(), 100); + assert_eq!(fs::read(old.join("credentials.json")).unwrap(), source); + assert!(old.join("master.key").is_file()); + broker.lock(); + broker.unlock(password()).unwrap(); + for (id, value) in fixture["values"].as_object().unwrap() { + assert_eq!( + broker + .resolve(&Scope::Provider, &CredentialId::legacy(id)) + .unwrap() + .unwrap() + .as_slice(), + value.as_str().unwrap().as_bytes() + ); + } + broker + .put( + &CredentialId::legacy("provider-000"), + Zeroizing::new(b"changed-new-value".to_vec()), + ) + .unwrap(); + assert_eq!( + broker.import_fernet(&old, None).unwrap_err(), + "MIGRATION_CONFLICT" + ); + assert_eq!( + broker + .resolve(&Scope::Provider, &CredentialId::legacy("provider-000")) + .unwrap() + .unwrap() + .as_slice(), + b"changed-new-value" + ); + } + #[test] + fn stronghold_roundtrip_scope_lock_and_password_rotation() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("credentials.v1"); + let mut broker = CredentialBroker::new(file.clone()); + broker.unlock(password()).unwrap(); + let id = CredentialId { + scope: Scope::Provider, + id: "provider-one".into(), + }; + broker + .put(&id, Zeroizing::new(b"fixture-secret-do-not-log".to_vec())) + .unwrap(); + assert!(broker.resolve(&Scope::Mcp("x".into()), &id).is_err()); + assert!(!fs::read(&file) + .unwrap() + .windows(b"fixture-secret-do-not-log".len()) + .any(|w| w == b"fixture-secret-do-not-log")); + broker.lock(); + assert_eq!( + broker.resolve(&Scope::Provider, &id).unwrap_err(), + "CREDENTIALS_LOCKED" + ); + broker.unlock(password()).unwrap(); + assert_eq!( + broker + .resolve(&Scope::Provider, &id) + .unwrap() + .unwrap() + .as_slice(), + b"fixture-secret-do-not-log" + ); + broker + .change_password(Zeroizing::new(b"second-test-password".to_vec())) + .unwrap(); + broker.lock(); + assert!(broker.unlock(password()).is_err()); + broker + .unlock(Zeroizing::new(b"second-test-password".to_vec())) + .unwrap(); + assert_eq!(broker.list().unwrap().len(), 1); + broker.delete(&id).unwrap(); + assert!(broker.resolve(&Scope::Provider, &id).unwrap().is_none()); + } + #[test] + fn corrupt_store_is_not_recreated() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("credentials.v1"); + fs::write(&path, b"corrupt").unwrap(); + let mut broker = CredentialBroker::new(path.clone()); + assert!(broker.unlock(password()).is_err()); + assert_eq!(fs::read(path).unwrap(), b"corrupt"); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 54aebb2..7e04922 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 +pub mod core; +pub mod credentials; pub mod recent; +mod runtime_compat; pub mod workspace; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index d087c2d..ba03154 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -3,17 +3,24 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use notesagent_host::core::CoreSupervisor; +use notesagent_host::credentials::CredentialBroker; use notesagent_host::recent::{RecentVault, RecentVaultStore}; use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; +use std::collections::HashMap; use std::path::Path; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{Emitter, Manager, State}; +use zeroize::Zeroizing; #[derive(Default)] struct Host { workspace: Mutex>, recent: Mutex>, + core: Arc>>, + credentials: Arc>>, + streams: Arc>>>, } fn info(ws: &Workspace) -> RecentVault { @@ -39,8 +46,14 @@ fn with_workspace( } #[tauri::command] -fn host_capabilities() -> serde_json::Value { - serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"}) +fn host_capabilities(host: State<'_, Host>) -> serde_json::Value { + let ready = host + .core + .try_lock() + .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":false,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"}) } #[derive(serde::Serialize)] @@ -65,14 +78,9 @@ fn is_json_content_type(content_type: &str) -> bool { media_type == "application/json" || media_type.ends_with("+json") } +#[cfg(test)] fn core_url(path: &str) -> Result { - if (!path.starts_with("/api/") && path != "/api" && path != "/health") - || path.contains("..") - || path.contains(['\r', '\n']) - { - return Err("CORE_PATH_DENIED".into()); - } - Ok(format!("http://127.0.0.1:8000{path}")) + notesagent_host::core::checked_url(8000, path) } #[cfg(test)] @@ -108,14 +116,28 @@ mod core_proxy_tests { } } -/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。 +/// Authenticated process-local transport; session headers are owned by Rust. #[tauri::command] async fn core_request( method: String, path: String, body: Option, - authorization: Option, + body_base64: Option, + content_type: Option, + idempotency_key: Option, + host: State<'_, Host>, ) -> Result { + let core = host.core.clone(); + let core_path = path.clone(); + let session = tauri::async_runtime::spawn_blocking(move || { + core.lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("CORE_UNAVAILABLE")? + .request_session(&core_path) + }) + .await + .map_err(|_| "CORE_UNAVAILABLE")??; let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?; if !matches!( @@ -130,16 +152,47 @@ async fn core_request( } let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() .build() .map_err(|_| "CORE_CLIENT_ERROR")?; - let mut request = client.request(method, core_url(&path)?); + let mut request = client + .request(method, &session.url) + .header( + reqwest::header::AUTHORIZATION, + session.authorization.as_str(), + ) + .header("X-Core-Generation", &session.generation); if let Some(value) = body { request = request.json(&value); } - if let Some(value) = authorization { - request = request.header(reqwest::header::AUTHORIZATION, value); + if let Some(encoded) = body_base64 { + if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + let bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|_| "CORE_BODY_INVALID")?; + if bytes.len() > MAX_CORE_RESPONSE_BYTES { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + let content_type = content_type + .as_deref() + .unwrap_or("application/octet-stream"); + if !matches!(content_type, "application/octet-stream" | "application/zip") { + return Err("CORE_CONTENT_TYPE_DENIED".into()); + } + request = request + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(bytes); } - let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; + if let Some(key) = idempotency_key { + if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { + return Err("CORE_HEADER_INVALID".into()); + } + request = request.header("Idempotency-Key", key); + } + let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; let status = response.status().as_u16(); let content_type = response .headers() @@ -153,9 +206,12 @@ async fn core_request( { return Err("CORE_RESPONSE_TOO_LARGE".into()); } - let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?; - if bytes.len() > MAX_CORE_RESPONSE_BYTES { - return Err("CORE_RESPONSE_TOO_LARGE".into()); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? { + if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES { + return Err("CORE_RESPONSE_TOO_LARGE".into()); + } + bytes.extend_from_slice(&chunk); } let (body, body_base64) = if is_json_content_type(&content_type) { ( @@ -173,6 +229,182 @@ async fn core_request( }) } +#[tauri::command] +fn core_stream_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> { + if let Some(task) = host + .streams + .lock() + .map_err(|_| "HOST_BUSY")? + .remove(&request_id) + { + task.abort(); + } + Ok(()) +} + +#[tauri::command] +fn core_stream( + host: State<'_, Host>, + request_id: String, + path: String, + method: String, + body: Option, + last_event_id: Option, + channel: tauri::ipc::Channel, +) -> Result<(), String> { + uuid::Uuid::parse_str(&request_id).map_err(|_| "CORE_REQUEST_ID_INVALID")?; + if !matches!(method.as_str(), "GET" | "POST") { + return Err("CORE_METHOD_DENIED".into()); + } + if body + .as_ref() + .is_some_and(|b| b.to_string().len() > 1024 * 1024) + { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + if last_event_id + .as_ref() + .is_some_and(|s| s.len() > 128 || s.contains(['\r', '\n'])) + { + return Err("CORE_HEADER_INVALID".into()); + } + let core = host.core.clone(); + let streams = host.streams.clone(); + let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?; + if running.len() >= 16 || running.contains_key(&request_id) { + return Err("CORE_STREAM_LIMIT".into()); + } + let id = request_id.clone(); + let task = tauri::async_runtime::spawn(async move { + let result: Result<(), String> = async { + let session = tauri::async_runtime::spawn_blocking(move || { + core.lock().map_err(|_| "HOST_BUSY")?.as_mut().ok_or("CORE_UNAVAILABLE")?.request_session(&path) + }).await.map_err(|_| "CORE_UNAVAILABLE")??; + let client = reqwest::Client::builder().no_proxy() + .redirect(reqwest::redirect::Policy::none()).timeout(Duration::from_secs(600)) + .build().map_err(|_| "CORE_CLIENT_ERROR")?; + let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url) + .header("Authorization", session.authorization.as_str()) + .header("X-Core-Generation", session.generation).header("Accept", "text/event-stream"); + if let Some(body) = body { request = request.json(&body); } + if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); } + let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; + channel.send(serde_json::json!({"kind":"headers","status":response.status().as_u16()})).map_err(|_| "CORE_STREAM_CLOSED")?; + let mut size = 0usize; + while let Some(bytes) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? { + size = size.saturating_add(bytes.len()); + if size > MAX_CORE_RESPONSE_BYTES { return Err("CORE_RESPONSE_TOO_LARGE".into()); } + for chunk in bytes.chunks(16384) { + channel.send(serde_json::json!({"kind":"chunk","data":BASE64_STANDARD.encode(chunk)})).map_err(|_| "CORE_STREAM_CLOSED")?; + } + } + Ok(()) + }.await; + match result { + Ok(()) => { + let _ = channel.send(serde_json::json!({"kind":"done"})); + } + Err(code) => { + let _ = channel.send(serde_json::json!({"kind":"error","code":code})); + } + } + if let Ok(mut running) = streams.lock() { + running.remove(&id); + } + }); + running.insert(request_id, task); + Ok(()) +} + +#[tauri::command] +fn credentials_status(host: State<'_, Host>) -> Result { + let broker = host + .credentials + .try_lock() + .map_err(|_| "CREDENTIALS_BUSY")?; + let broker = broker.as_ref().ok_or("HOST_NOT_READY")?; + Ok(serde_json::json!({"locked":broker.is_locked()})) +} + +#[tauri::command] +async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> { + let broker = host.credentials.clone(); + let password = Zeroizing::new(password.into_bytes()); + tauri::async_runtime::spawn_blocking(move || { + broker + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .unlock(password) + }) + .await + .map_err(|_| "HOST_BUSY")? +} + +#[tauri::command] +fn credentials_lock(host: State<'_, Host>) -> Result<(), String> { + host.credentials + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .lock(); + Ok(()) +} + +#[tauri::command] +async fn credentials_import(host: State<'_, Host>) -> Result, String> { + let Some(path) = rfd::FileDialog::new() + .set_title("选择旧版本的 credentials.json(不会删除原文件)") + .add_filter("Fernet credentials", &["json"]) + .pick_file() + else { + return Ok(None); + }; + if path.file_name().and_then(|n| n.to_str()) != Some("credentials.json") { + return Err("MIGRATION_SOURCE_INVALID".into()); + } + let directory = path + .parent() + .ok_or("MIGRATION_SOURCE_INVALID")? + .to_path_buf(); + let broker = host.credentials.clone(); + let environment_key = std::env::var("APP_CREDENTIAL_MASTER_KEY") + .ok() + .map(Zeroizing::new); + tauri::async_runtime::spawn_blocking(move || { + broker + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .import_fernet(&directory, environment_key) + .map(Some) + }) + .await + .map_err(|_| "HOST_BUSY")? +} + +#[tauri::command] +async fn credentials_change_password( + host: State<'_, Host>, + password: String, +) -> Result<(), String> { + let broker = host.credentials.clone(); + let password = Zeroizing::new(password.into_bytes()); + tauri::async_runtime::spawn_blocking(move || { + broker + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .change_password(password) + }) + .await + .map_err(|_| "HOST_BUSY")? +} + #[tauri::command] fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> { app.state::>() @@ -314,6 +546,65 @@ fn main() { .lock() .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); + let credential_state = app.state::().credentials.clone(); + *credential_state + .lock() + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new( + app.path().app_data_dir()?.join("credentials/stronghold.v1"), + )); + let data_dir = app.path().app_data_dir()?.join("core-data"); + // Debug builds use this worktree's interpreter; release builds only use bundled Core. + let core = if cfg!(debug_assertions) { + let backend = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../backend") + .canonicalize()?; + let python = backend.join(if cfg!(windows) { + ".venv/Scripts/python.exe" + } else { + ".venv/bin/python" + }); + CoreSupervisor::new( + python, + vec!["-m".into(), "app.sidecar".into()], + backend, + data_dir, + ) + } else { + let root = app.path().resource_dir()?.join("core"); + CoreSupervisor::new( + root.join(if cfg!(windows) { + "opennexus-core.exe" + } else { + "opennexus-core" + }), + vec![], + root, + data_dir, + ) + .with_bundle_manifest( + include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(), + ) + }; + let core = core.with_broker(Arc::new(move |request| { + credential_state + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .dispatch(request) + })); + *app.state::() + .core + .lock() + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(core); + let handle = app.handle().clone(); + std::thread::spawn(move || { + if let Ok(mut core) = handle.state::().core.lock() { + if let Some(core) = core.as_mut() { + let _ = core.start(); + } + } + }); Ok(()) }) .on_menu_event(|app, event| { @@ -330,7 +621,14 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ host_capabilities, + credentials_status, + credentials_unlock, + credentials_lock, + credentials_change_password, + credentials_import, core_request, + core_stream, + core_stream_cancel, editor_capabilities, workspace_choose, workspace_open, @@ -343,6 +641,16 @@ fn main() { workspace_delete, workspace_mkdir ]) - .run(tauri::generate_context!()) - .expect("桌面 Host 启动失败"); + .build(tauri::generate_context!()) + .expect("桌面 Host 启动失败") + .run(|app, event| { + if let tauri::RunEvent::Exit = event { + if let Ok(mut broker) = app.state::().credentials.lock() { + broker.take(); + } + if let Ok(mut core) = app.state::().core.lock() { + core.take(); + } + } + }); } diff --git a/frontend/src-tauri/src/runtime_compat.rs b/frontend/src-tauri/src/runtime_compat.rs new file mode 100644 index 0000000..a77dc58 --- /dev/null +++ b/frontend/src-tauri/src/runtime_compat.rs @@ -0,0 +1,39 @@ +//! C23 compatibility for the MSVCRT-based Windows GNU development target. +//! Recent libsodium archives reference memset_explicit, absent in MSVCRT. +//! Volatile stores preserve its non-elidable wipe semantics; MSVC/UCRT release +//! builds use their native runtime and do not compile this compatibility symbol. + +#[cfg(all(windows, target_env = "gnu"))] +#[no_mangle] +unsafe extern "C" fn memset_explicit( + destination: *mut std::ffi::c_void, + value: std::ffi::c_int, + count: usize, +) -> *mut std::ffi::c_void { + for offset in 0..count { + // SAFETY: the C ABI caller must supply a writable region of count bytes, + // exactly as for memset. Volatile stores cannot be removed as dead writes. + unsafe { + destination + .cast::() + .add(offset) + .write_volatile(value as u8); + } + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); + destination +} + +#[cfg(all(test, windows, target_env = "gnu"))] +mod tests { + #[test] + fn explicit_memset_preserves_surrounding_bytes_and_return_pointer() { + let mut data = [0x55u8; 34]; + let pointer = data[1..33].as_mut_ptr().cast(); + // SAFETY: the subslice contains exactly 32 writable bytes. + assert_eq!(unsafe { super::memset_explicit(pointer, 0, 32) }, pointer); + assert_eq!(data[0], 0x55); + assert_eq!(data[33], 0x55); + assert!(data[1..33].iter().all(|b| *b == 0)); + } +} diff --git a/frontend/src-tauri/tauri.bundle.conf.json b/frontend/src-tauri/tauri.bundle.conf.json new file mode 100644 index 0000000..3704a3f --- /dev/null +++ b/frontend/src-tauri/tauri.bundle.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "active": true, + "targets": ["nsis"], + "resources": {"../../.build/sidecar/dist/opennexus-core/": "core/"} + } +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 2612a0b..ccb350f 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "NotesAgent Preview", + "productName": "OpenNexus", "version": "0.3.0-alpha.1", "identifier": "cc.kronecker.notesagent", "build": { @@ -10,9 +10,9 @@ "frontendDist": "../dist" }, "app": { - "windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}], + "windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}], "security": { - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", "capabilities": ["main"] } }, diff --git a/frontend/src-tauri/tests/core_bundle.rs b/frontend/src-tauri/tests/core_bundle.rs new file mode 100644 index 0000000..1e8240b --- /dev/null +++ b/frontend/src-tauri/tests/core_bundle.rs @@ -0,0 +1,21 @@ +use notesagent_host::core::verify_bundle; + +#[test] +fn bundle_rejects_modified_missing_and_extra_files() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("opennexus-core.exe"); + std::fs::write(&file, b"abc").unwrap(); + let manifest = r#"{"protocol":1,"product":"OpenNexus","files":{"opennexus-core.exe":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}}"#; + verify_bundle(dir.path(), manifest).unwrap(); + std::fs::write(&file, b"abd").unwrap(); + assert_eq!( + verify_bundle(dir.path(), manifest).unwrap_err(), + "CORE_INTEGRITY_FAILED" + ); + std::fs::write(&file, b"abc").unwrap(); + std::fs::write(dir.path().join("injected.dll"), b"malicious").unwrap(); + assert!(verify_bundle(dir.path(), manifest).is_err()); + std::fs::remove_file(dir.path().join("injected.dll")).unwrap(); + std::fs::remove_file(file).unwrap(); + assert!(verify_bundle(dir.path(), manifest).is_err()); +} diff --git a/frontend/src-tauri/tests/core_process.rs b/frontend/src-tauri/tests/core_process.rs new file mode 100644 index 0000000..8921b85 --- /dev/null +++ b/frontend/src-tauri/tests/core_process.rs @@ -0,0 +1,123 @@ +//! Executes the real worktree Core, with no personal data or external Provider. +use notesagent_host::core::CoreSupervisor; +use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use zeroize::Zeroizing; + +#[test] +fn real_python_core_authenticates_and_rotates_generation() { + let backend = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../backend") + .canonicalize() + .unwrap(); + let python = backend.join(if cfg!(windows) { + ".venv/Scripts/python.exe" + } else { + ".venv/bin/python" + }); + assert!( + python.is_file(), + "Create the isolated backend environment with uv sync --frozen" + ); + let temp = tempfile::tempdir().unwrap(); + let mut first = CoreSupervisor::new( + python.clone(), + vec!["-m".into(), "app.sidecar".into()], + backend.clone(), + temp.path().join("one"), + ); + let request = first.request_session("/health").unwrap(); + assert!(first.available()); + assert!(request.url.starts_with("http://127.0.0.1:")); + assert!(!request.url.contains(request.authorization.as_str())); + let mut second = CoreSupervisor::new( + python, + vec!["-m".into(), "app.sidecar".into()], + backend, + temp.path().join("two"), + ); + let other = second.request_session("/health").unwrap(); + assert_ne!(request.generation, other.generation); + assert_ne!(request.authorization.as_str(), other.authorization.as_str()); + drop(first); + let endpoint = request + .url + .trim_start_matches("http://") + .trim_end_matches("/health"); + assert!(std::net::TcpStream::connect(endpoint).is_err()); +} + +#[test] +fn real_core_credential_api_uses_host_stronghold_without_plaintext_response() { + use std::io::{Read, Write}; + let backend = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../backend") + .canonicalize() + .unwrap(); + let python = backend.join(if cfg!(windows) { + ".venv/Scripts/python.exe" + } else { + ".venv/bin/python" + }); + let temp = tempfile::tempdir().unwrap(); + let broker = Arc::new(Mutex::new(CredentialBroker::new( + temp.path().join("stronghold.v1"), + ))); + broker + .lock() + .unwrap() + .unlock(Zeroizing::new(b"controlled-fixture-password".to_vec())) + .unwrap(); + let handler = broker.clone(); + let mut core = CoreSupervisor::new( + python, + vec!["-m".into(), "app.sidecar".into()], + backend, + temp.path().join("core"), + ) + .with_broker(Arc::new(move |request| { + handler.lock().unwrap().dispatch(request) + })); + let session = core + .request_session("/api/credentials/fixture-provider") + .unwrap(); + let endpoint = session + .url + .trim_start_matches("http://") + .split('/') + .next() + .unwrap(); + let body = r#"{"api_key":"fixture-credential-via-host-pipe"}"#; + let mut socket = std::net::TcpStream::connect(endpoint).unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .unwrap(); + write!(socket, "PUT /api/credentials/fixture-provider HTTP/1.1\r\nHost: {endpoint}\r\nAuthorization: {}\r\nX-Core-Generation: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", session.authorization.as_str(), session.generation, body.len()).unwrap(); + let mut response = String::new(); + socket.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 200"), + "credential API did not succeed" + ); + assert!(!response.contains("fixture-credential-via-host-pipe")); + let id = CredentialId { + scope: Scope::Provider, + id: "fixture-provider".into(), + }; + assert_eq!( + broker + .lock() + .unwrap() + .resolve(&Scope::Provider, &id) + .unwrap() + .as_deref() + .map(|s| s.as_slice()), + Some(b"fixture-credential-via-host-pipe".as_slice()) + ); + assert!(!temp.path().join("core/credentials/master.key").exists()); + assert!(!temp + .path() + .join("core/credentials/credentials.json") + .exists()); +} diff --git a/frontend/src-tauri/tests/fixtures/fernet-python.json b/frontend/src-tauri/tests/fixtures/fernet-python.json new file mode 100644 index 0000000..d4a2484 --- /dev/null +++ b/frontend/src-tauri/tests/fixtures/fernet-python.json @@ -0,0 +1,208 @@ +{ + "test_only": true, + "key": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=", + "values": { + "provider-000": "fixture-secret-000", + "provider-001": "fixture-secret-001", + "provider-002": "fixture-secret-002", + "provider-003": "fixture-secret-003", + "provider-004": "fixture-secret-004", + "provider-005": "fixture-secret-005", + "provider-006": "fixture-secret-006", + "provider-007": "fixture-secret-007", + "provider-008": "fixture-secret-008", + "provider-009": "fixture-secret-009", + "provider-010": "fixture-secret-010", + "provider-011": "fixture-secret-011", + "provider-012": "fixture-secret-012", + "provider-013": "fixture-secret-013", + "provider-014": "fixture-secret-014", + "provider-015": "fixture-secret-015", + "provider-016": "fixture-secret-016", + "provider-017": "fixture-secret-017", + "provider-018": "fixture-secret-018", + "provider-019": "fixture-secret-019", + "provider-020": "fixture-secret-020", + "provider-021": "fixture-secret-021", + "provider-022": "fixture-secret-022", + "provider-023": "fixture-secret-023", + "provider-024": "fixture-secret-024", + "provider-025": "fixture-secret-025", + "provider-026": "fixture-secret-026", + "provider-027": "fixture-secret-027", + "provider-028": "fixture-secret-028", + "provider-029": "fixture-secret-029", + "provider-030": "fixture-secret-030", + "provider-031": "fixture-secret-031", + "provider-032": "fixture-secret-032", + "provider-033": "fixture-secret-033", + "provider-034": "fixture-secret-034", + "provider-035": "fixture-secret-035", + "provider-036": "fixture-secret-036", + "provider-037": "fixture-secret-037", + "provider-038": "fixture-secret-038", + "provider-039": "fixture-secret-039", + "provider-040": "fixture-secret-040", + "provider-041": "fixture-secret-041", + "provider-042": "fixture-secret-042", + "provider-043": "fixture-secret-043", + "provider-044": "fixture-secret-044", + "provider-045": "fixture-secret-045", + "provider-046": "fixture-secret-046", + "provider-047": "fixture-secret-047", + "provider-048": "fixture-secret-048", + "provider-049": "fixture-secret-049", + "provider-050": "fixture-secret-050", + "provider-051": "fixture-secret-051", + "provider-052": "fixture-secret-052", + "provider-053": "fixture-secret-053", + "provider-054": "fixture-secret-054", + "provider-055": "fixture-secret-055", + "provider-056": "fixture-secret-056", + "provider-057": "fixture-secret-057", + "provider-058": "fixture-secret-058", + "provider-059": "fixture-secret-059", + "provider-060": "fixture-secret-060", + "provider-061": "fixture-secret-061", + "provider-062": "fixture-secret-062", + "provider-063": "fixture-secret-063", + "provider-064": "fixture-secret-064", + "provider-065": "fixture-secret-065", + "provider-066": "fixture-secret-066", + "provider-067": "fixture-secret-067", + "provider-068": "fixture-secret-068", + "provider-069": "fixture-secret-069", + "provider-070": "fixture-secret-070", + "provider-071": "fixture-secret-071", + "provider-072": "fixture-secret-072", + "provider-073": "fixture-secret-073", + "provider-074": "fixture-secret-074", + "provider-075": "fixture-secret-075", + "provider-076": "fixture-secret-076", + "provider-077": "fixture-secret-077", + "provider-078": "fixture-secret-078", + "provider-079": "fixture-secret-079", + "provider-080": "fixture-secret-080", + "provider-081": "fixture-secret-081", + "provider-082": "fixture-secret-082", + "provider-083": "fixture-secret-083", + "provider-084": "fixture-secret-084", + "provider-085": "fixture-secret-085", + "provider-086": "fixture-secret-086", + "provider-087": "fixture-secret-087", + "provider-088": "fixture-secret-088", + "provider-089": "fixture-secret-089", + "provider-090": "fixture-secret-090", + "provider-091": "fixture-secret-091", + "provider-092": "fixture-secret-092", + "provider-093": "fixture-secret-093", + "provider-094": "fixture-secret-094", + "provider-095": "fixture-secret-095", + "provider-096": "fixture-secret-096", + "provider-097": "fixture-secret-097", + "provider-098": "fixture-secret-098", + "provider-099": "fixture-secret-099" + }, + "tokens": { + "provider-000": "gAAAAAAAD0JARqrDNs-yq83rOzN4Fr-VBvZUACnn102cROns5fJFMGCz2gV-CeZmVVD-Z3SRXFszpHy9GeF6VYqh6YIuT74GuLcs3OXhZbsjnHQLpSgFSEQ=", + "provider-001": "gAAAAAAAD0JAcVlmcEu1YtpyqZwpyVFDekfujUGGX9UvwikB9b5S1fJsFxkREFqvqCkriMyx1fBVVnBksgIkeQs_3wAiItJJ-85u-bI3s3tYNwwbRP3WJc8=", + "provider-002": "gAAAAAAAD0JAfzv-Gsa9LS-TsbJlGu85an8pvoelczxr_4ClBIOfAbb4hnp-BJFOrWrkUFIrdcUYVTNAFMAnCyHkBjk24qf9aHHRAIVhfhY1MOrHlEgmZVc=", + "provider-003": "gAAAAAAAD0JAhTIDM4rkBwD1THGSiV_dDcSypkhB9zpivxbtr8m5Pa2EQ5yZ_aZza28fYPIJctUYYqsp-g39F_p0zmqdMcdMDdEBu-5Rkk98VffMSxVNWX0=", + "provider-004": "gAAAAAAAD0JAL-SAKnLr5dP1fRGIBAheTO2ZmsSMSMzXGrkClU21EAAtr5PKbH00i2r5PjpRpt8419bI_sH16flA0N9Tsc-kXp1mNhCMJa8V5G04bMPG2vY=", + "provider-005": "gAAAAAAAD0JAILcE3HJND9p7zM2Zaar4wZyg1EJL53-qQ_mfmqB4fRGvtGwSBemeAo2jdMwzovmYcA7hWwZ4XYvpkNZgLQYAzDrdwFw4yqBR98iyVO1a72c=", + "provider-006": "gAAAAAAAD0JABOivDjaiCbjTCzY2K6QHqi1n0P3QRiBGzwQt2eutxv5sR1kyYYE4XI3LpI04AplSr6gY7KsXsbz-6pMQrafNFHvlBI8kVCe0MwgsbbBHjyY=", + "provider-007": "gAAAAAAAD0JAgjSooEXGu-V4mZCaxFuNMysTkuLcA54T7HO9lEFQmkJTzCPUjx_THiz5zMciilbPknCaD2HVOx5CSO2DJqXlgTiI2jBIjIDBmmIYHElrgzA=", + "provider-008": "gAAAAAAAD0JAVHspcU_6dp1IpisNd9XULSuwaSsTKKNcw5FtXtUCcLWUVpuFA9qy3DhVIteS83SVyxAOv9aJNc4cgb26z5Q_4-xjCBY2ubH-12pSdTkfsTI=", + "provider-009": "gAAAAAAAD0JAeu1fUbwRb26f07d9C1WPNaIXWuNmMSkulPITmVanLj9IGDQ2fdXf3pkqe171EaUUWiUo9plQC-xC0UOalBQ-HA7kBnPKQuTOqbmBUxOFbws=", + "provider-010": "gAAAAAAAD0JA-BxhhY0FDRAXCg7zAVmm1bHos6KVJt8NrqirUGm_OsWuttNHJw5FfWU_ZCird4uupxQrxpuil1c6FsKbLSfis5wd6cNXGAr0dnoG0bkAzEs=", + "provider-011": "gAAAAAAAD0JArJ3PzUpG7SJev_SpzXIFmpFNnr5uxjvjFmhEEYY2frrtcvheJzqUutllJ4dCecfgUhoFZYZoW6hZzF-VeEiGULtFD6cviCBIJ9rB3Q9n1eI=", + "provider-012": "gAAAAAAAD0JAWDG5yfwVQ5k6ZgFMcZ5BgQT37s9I_uZa24vfGBV7TOPGLDOD3WvejAH2XSm-p70JarQSj6o6f4P-slKBmmlOjBFlor8kN6k5QFKrvl_DrTE=", + "provider-013": "gAAAAAAAD0JAcM0ACOR4TnyaWMSQajkzlh6bpDQkXDiyIjN7JCzQGyPBQ5xnq5o5XVEmdy6s_2KtVNcZ7qn_kxXNvK1tfffgq-08u06MJpzdjDCjt3vBKd8=", + "provider-014": "gAAAAAAAD0JApy7Fg5LFSh_sXlwQ_mIJhropoOnNgfY__LSYrkK65NuNUttVD16i-gn0EZSulZfHLDOjhTLLyFwKOQfjoasuoucJK_R_gegpWu9rgkF2GkE=", + "provider-015": "gAAAAAAAD0JAEql5cEfuPRntNp5p-gIdyfj3Vfiz51KvIl1YISZMYWbQMHXSB1Kud9v5b3WZJGf3EbdCUyGl4-E3xYI85-wSJlCkYaXNJzGaTiZ1OFaqPiE=", + "provider-016": "gAAAAAAAD0JAvE3iFTQdIaFqyM8MCMmR4dxxQMeV-C65_o7m-8BZArFfxBgYTo9zZY_dEj0iVvAM7GORQPEiSr7wJqiOXffLbTsACHRWPMDV-EuKiptxpxc=", + "provider-017": "gAAAAAAAD0JA7RH-NOlL5ARehrlfzea96aDY4rQoE_awhZKZdUUJalJzhvxJJ8B3Z8GpzJlGgM6FSbQfZCIDv1NVh7jikZVfI4Sah1DvS08dx6NrpPyKFp8=", + "provider-018": "gAAAAAAAD0JAQgNdqKNGx1wr-Rz3q9E3nT_Fzhn2Kj-L8XA57p1u6MpHuhUbMTWxojYQKkV5rBW-9ZDPHj4owk1Ac7At0bhoDtI5H-kK-Gj8wmlnfKxUh_I=", + "provider-019": "gAAAAAAAD0JAGLkac-dCevEfv4vHLfGFKav-sjkU1pSGtS1ChrgxKbexQZBsrxW3ThGrpn3zIsjQU-SqpykV3FJF0rRlUDtSy8Du3BMlgNpC2JoyhbUBb1w=", + "provider-020": "gAAAAAAAD0JACZACHyAREZW672Crakxaa0t7R-GFUtqFscUX8nr51oriHlZH3jOu0t6HRAo4KARxT0ZKpnBhlGfM4szpZlhMFwg__WKtMqpxWRrlKYJjh8s=", + "provider-021": "gAAAAAAAD0JAmTxi0REmhen9tXCjNANx2Ik7g2TVVzak-s_hWzwHFlRATxsHg60bpQTNpXFZaC_Z4lSdEO5RMJHqHo3ZT3Gjny4nNqR22fJxsqBJIwC8OFE=", + "provider-022": "gAAAAAAAD0JAPp5h1z5QGy8rwNjNgo7NIGOVC8cnTLG-xL-if6YHLJnQ9DQJ4n6cac3QHfPA-gi1YM6ZalLxbxUy-roueoT59lGQ1Y4OtFDy1jlSXKRn5TY=", + "provider-023": "gAAAAAAAD0JAs0IfMhthrgdTZenjN8gshQGRkiyZG7t2PZfaDv9konfxahHbFFzAW_-U-TKXJQRiXlvSZ_p_3e0upcMJ0Rg8bPPHmo3RwWpe3DD8BwYzY0Q=", + "provider-024": "gAAAAAAAD0JA6DBcyT9ftGfYVoP0aWBdGJENSHu6Jq3z20J004OOcJlHf63zKL4vYR0ZWUfBvpHDz_I2Ol0gLn2woTPv6ul0WB3tzDipo1cH9SUeUS3BUQI=", + "provider-025": "gAAAAAAAD0JA38z2-MzmaHBI--1UAZs8Rxwm7SoDgKPlapvRKrr7zNrI5YVK9v9ZrzJJ3He0oZaKAw3DvYBk9NTJoZuT5Y7eFRX5sHagSlmTLaoysNCFZQg=", + "provider-026": "gAAAAAAAD0JA78csksdCk34WXqhqPIXRd7QpxkoAlrudALMtTuLXOEmIoEIhv2d28fQFok2aq-CAS6CcVCVJhx2hvgpV6PG09tJSWDZTBjqqSp-ANCQsEy8=", + "provider-027": "gAAAAAAAD0JAPLfJkfciuZBZxQFfKsFTVRAGEVRPXGQk_v5WpwvIpmJI0ng_xfeX0T8De9RD02yUuEslI_Gfh_nm-47yVwNIgw-vN3_07eMKKENRNQlKwDk=", + "provider-028": "gAAAAAAAD0JAOQYHZstCBM1A1RPKfHZF67nerogpSQo_sw6pobxsr6oOQ4mNXoDksfEvdLmBzHTQT30UHFbbVHW3r3uMoinjv23-02g9nVM8oOu3mYoZRYs=", + "provider-029": "gAAAAAAAD0JAVGmZPBVRSgBNVGHWbVgUfjP6X2s9CZGenb4c1NHMEC8Oj6Ux8Pwyo0jqyxUMhkqBS8HG7GcWliYzoooxP8b1vN4bXB8GkzfCm2Co-Jfo52A=", + "provider-030": "gAAAAAAAD0JAI6-pQy8G-U3m0S62sMIWPbjndADsysZinZcMlvaH42i-iWCufh-b7MLLh8E1Ki6BQOfbq6K7JyFLL0moarHvuiUNX71rMwFDw6yutshda6Q=", + "provider-031": "gAAAAAAAD0JARThZQX-8Aeoc9M2eNUSwZfQhXmgGmrY6f_nSLbKnM2v8Jivmz-0yLqBTgEQK5firYXim_Uysj09gSC3Bw0_TO7-IkDAQrqNFmVEhwN__Jyk=", + "provider-032": "gAAAAAAAD0JAkEIJgqfvq3ts1jKBYSFDahPn8c7uieUzckrvezbhrfbHEn57BpdH0A6xjFCzBiLlIbU5h-THiVQalKBP8fH80G3GrAcvlx7_9n-KbmE_g1E=", + "provider-033": "gAAAAAAAD0JADU8fN01753ribq0QDDJK9zNgYSgboF-xwpIKJFvwL6GUPr07JCtkz1riGtyU7IYWQHNIx9FyC8G9Jp6eBTycB9Dhm2S4t6X62z8HcG2tG5A=", + "provider-034": "gAAAAAAAD0JA8PhvLE-sVw_bkdcfdr2AX_wVJHWVZDMgln1cOptYmYIdF2ToreQIyNz0ElcSIVZCfyMsKCehz6cuCcetZoLVtBrL5V1ZpWGjqOviXsG5N1Y=", + "provider-035": "gAAAAAAAD0JA29XAVBWD5VjF6Yzmm1PbTESQqkCXB12yyMuT4MnJVweqlMJRX6IJtSfxjwzavko_xLXxHdIv9jYb3sHNXGUpNI5vA-VVDxL928PX695NyXc=", + "provider-036": "gAAAAAAAD0JAUfb73PYyObLEaulCDuLzUNOBDFb1P-GlowbmYxSIlX1Y2A_4FluLopM5XRQl-p94SkwnGZuu_wdieoBpXoz4RZMuKgLlYMfI_B6QAh4zGtI=", + "provider-037": "gAAAAAAAD0JATbfi9uRGgV64yB1u1XLytvUWRGXu8c5bIjr_N6jxUfw8CL4QTtAj092R2FT2lZ2p3x122MU4AyQJFZO-3ARnV1co7w8_gcIoJ3WP19knGIk=", + "provider-038": "gAAAAAAAD0JAKQ3N0O10RN4RId6ZtU2N5fB95sJ4Rt29PQx6VY452GgvttuZFnLJjG44xL4Et_ml27t4Ylu507I86E14yjPI18AnhtagNc0Or-mX3C5J6BY=", + "provider-039": "gAAAAAAAD0JAUmO46r3Stv4c6PLSZxp4vsh1fDvD34e-ukpuyoc6LSO-ijgP20L3Fl2medshUT9KBhgTIXmacUlmZwHD45TPyUsiuh09p6ZrZNuKtlwtcFM=", + "provider-040": "gAAAAAAAD0JAqkGVoQO8cLp-saeL-C5oB_VJh-Oln-HgJgrUlxBCUrsbdYO6oXwUjFWIY-mf9sbide-FlJ9ypbsiuOXWTkkMViIwBntF5P6IduR9fpvhFTQ=", + "provider-041": "gAAAAAAAD0JA0CnlOHJXTwBoeeg0xIxHSi-sn0b4pExJ-z_omfIuvgQYEI2t8FuVK5BVmiqqhpF-X0nOyGJScyNYD30G6s1kXJGkk2wczfAJ56Gr9evt4y0=", + "provider-042": "gAAAAAAAD0JABqbpp-3wu10rJYC_Bcw0PjTS_NSQZ2T2cRbCrP8qX4kOtYF5G8ihmCT3ir427NouPKKiJM4xJLDmVBGohyLwLreGYZJsGrJPTSbvrMevfv8=", + "provider-043": "gAAAAAAAD0JAnZiu7eyx9mQys1lBbkVAIUP2_CyClX39hyEXUuheuvQni0W0vrGqtuwl3g_eQo1UiiO-BoI0TGwJ8T5Kz-vFhbfDnkwEXLTNkVhxXqAgsS0=", + "provider-044": "gAAAAAAAD0JALfymh09LXoeVEQue5fExnPn_dSc5brevZ1IGCGvdboQkRFHrI1bTxBzVjT4mKxZq1C5pmBEdaapiwzyV11vYUvugl-Rw8ofbgi13Bm_OFS8=", + "provider-045": "gAAAAAAAD0JAjmxdzZpREmCgS2125qbKBxTJOlgb6dRvVQqdyyQK7TvlNXD4M9CpJRcbdGq7QOJHvl0lse1lUUSN9wi45O2RzNvEW4qSlbKx65h291kXXe0=", + "provider-046": "gAAAAAAAD0JA6H3YMHtFKu3oKtBOprisdbrA7yFb-bgCUkoQLwtw6NlSjXnIzFczOaQlN7j8Thgt3T_1YnX6zI-3WiJSKwbEWic1XEo3SCRn4TJHI_7X4EE=", + "provider-047": "gAAAAAAAD0JAw6-N0FfdvMGI3-zJ5fYHlr_Ki0CdiYB30XUIAkOzJKD7uPotZRYmjfWGvk-slhyuRR6I2H45I_uJ3L7pnXKwULushf5ssYcYBTCcDcUY13s=", + "provider-048": "gAAAAAAAD0JAZI__BvdD6dY6BOg2YaQdnBzVnGVTPWSFl_67ImM6DKzVnOho-eGaeWpONjJhXFEjJnc1GzhdjgznHJldUG6bz7wzq25sOw_-ZMgJ4xzTYTw=", + "provider-049": "gAAAAAAAD0JAuxRPYRIYy6cCClZj9lsRSadWjicUxThqfll0rL0OaAMYyxw3Y94c7AmJQle3XEY7rtTXrfpXa5OvOD7LSQyKmDayG_Ao7LLFlJxjHXVQnu8=", + "provider-050": "gAAAAAAAD0JAIb2OzqcQxC_Lzb4d7YT0hbvIYzb0GZTx7_6pFosrUk6tMl3FrqP11tNhFpjDoie1WV-eL-XK9hXkS74v3icXNwXa8V7hCMIxVOP65sSWtCw=", + "provider-051": "gAAAAAAAD0JA3kE4__P4FjzyW7fYbl30VkuGWesb2qr-Yx3RE5eZNvyFKo_t1p_smwaq4Wvz12AgCHmQ1gcBZzylVRgK3eG5fAuUYqfMjFwawfTyL3WURE8=", + "provider-052": "gAAAAAAAD0JAIZ5MtE8IOu0hJmzygrrcj-_Ll2AHcBw30rAx9OoVJWh_aSPL2QYjzcGO8xeS_1C_tknFw1WhVF5Uan46IXARdWOY7bnSSVnNKZMX_x_6gDE=", + "provider-053": "gAAAAAAAD0JAwf1PNFe3MaDq7GenXs_Td2zi_cn6FBWcl-tXt_oD2mew2eJ1i30A-IQQMVGuq4nA_wcBuwO8vCJ522XIpLYvhjQE1a1wZkEMtGniRgkJBM8=", + "provider-054": "gAAAAAAAD0JA0f8SsCpmEdpznx_GZo0nuyVEDZaK1GqGMKPqa8T-g4HViS34KovNriBZc-C5Vli7BVplJ547qSGQiATAYiDS69FvE9Mo_fK_RpZsaSVjq3A=", + "provider-055": "gAAAAAAAD0JAj9lMUNoW_vcgRaRHtOCfaaejTQCRMJw_jkObw0r26C7nVgt-Pu7HZm4_9kHuYl4fkm8MbAHIeXfbQp0R9HANDycyx-PSibOk2tGsFFUe9BU=", + "provider-056": "gAAAAAAAD0JAV1dj3mzw5D9bCUv1PZ5Qx2kR0tnhGm37xWxEqS9N-nVj8hu36dkcvJa43Nls7k4ZmQR1mlB00cKECr8M118QG740QMLBHc8HT0EtMuaHUHw=", + "provider-057": "gAAAAAAAD0JA0JIBXd3l6bDibgxCjyHjRdnaoPA2IIaZw1YvtFFc7REAmwloQ2ytnTVN_5lDzWMmtt5tI8dhlD4FqiD9i_ehn-Tom8sMbOU_MkprTtGdQZY=", + "provider-058": "gAAAAAAAD0JAYSoZSA2Gys7bL23LvdCLcvSIeG0kMvY2Az6equFSKGzSHCs2gUF12s7Z-AlFLmRvl5Kw2ZFGqFg0V0OEnKFV_-SCnYzToJ8QPIhHrtRO8t0=", + "provider-059": "gAAAAAAAD0JAUB2Rf_yW5j12i_K693ebcPB24HgRUbhFrMA3ylL-s_MTMqSHATUEDOcA6hLYrbnHkhRqibKUSaSVPt7n0STk90m7Tt5m6KHdmYH1pM09NFo=", + "provider-060": "gAAAAAAAD0JA9DBlKLX-bXYcJhNvHPs1m4_ndWfcuZWU0kMvqhY8hBD0nvt0-UZiNANE33LVrGpSoK5IUbUq6uIAqgiU_Q0lyYWD6Zrv1NL0nozegE2MFYI=", + "provider-061": "gAAAAAAAD0JAwW5G-mJSUkSV4CIBLPtJKQvNKd2Ssjy-srdvExhpPBGAwkIv9yCu_dgM7fBLHRoNuT2cA1FYUu0qKu_lu1Yskxc_g9HG8oZ6WgrUGdmgyTo=", + "provider-062": "gAAAAAAAD0JAbgX9K0OcfZpbb6cK7dJ7Q1tRQLaWCqJNYchBLDgD7N_tC0tOdYHS6Z623oomyUGgooYGKvF6jPhV1LAnD3RtXIpT6OfgDC5hhijKXBGDK5U=", + "provider-063": "gAAAAAAAD0JAKrZAEjUOgmSnTWdpa6wQWOaawO5cK6vwZhJlAaQIOF6SlCURV2-cfNwg5l2vm7ilhP12Qai7S2ljVP3fjsxqN0RcJY4ufUszuwCS-wDcfiM=", + "provider-064": "gAAAAAAAD0JANTxIEMEAxmFDOR4Trm7BZsCRtJLohH0sIrNhhnksy_RwR8lAWddm1Mve-9hy4vvxAuLR1pKRntyK02DD6xSD91Zv-nFN2mhwxww_9GMAUq4=", + "provider-065": "gAAAAAAAD0JAlXfTEsE0QOarTDgc9DG2WNc1kmjx7Jx5EfC1csicneTjsyl6sSb1Et5u5d-OmxcYupRWa3fplAVQyyHSkOtVims1v4rdshKjEsmRHj0j_H4=", + "provider-066": "gAAAAAAAD0JA0MZiSxiJA248fgj7jpUFHde2rQfPIckAKCF515JuBZaVyHEVDdEiog_gyth_QJHERpvJFU58weSlUA-UQRco-mZKaR4WJl5Pt2jVhYQ5SWU=", + "provider-067": "gAAAAAAAD0JAe7XCcR5qvu8WVXZO9hORAQGbhVJEwd3PRSzMq0HZFaIyIfRaIxT7t08GOVmX6P28c3ChTdzq4NPmnbCPLa0xWJ553A_jwszzKw7KnewMapY=", + "provider-068": "gAAAAAAAD0JAUc1xmeaMgsjTVn0o6z0ip-rtMssELSXWGZq45_B_YSlu2jYCocM7R2kKiZ26DMqFh1IEAOviiaTcbI74O9ofpoSD7mg48BAP4rZG1xOHXak=", + "provider-069": "gAAAAAAAD0JABmFuB-1Z_DiB6GFP7eqWCkJpcQmewraRzIdlpoxiiY-TN7m1m6jWFjL2yg9yAAagE2_RoNGbgcW8suvFPlgxbqxlYcF40PcEKxCQFwS_nxE=", + "provider-070": "gAAAAAAAD0JA6Sq2H9uxMrjMJle4M82jhKIxAjskPb_I1eas6Sn60wYQsP9JqiCAFm99a6vpEgWH9gJI9HdLFrmVv7CKy7FIlSl8zO61cfbvxLm78QqG9vM=", + "provider-071": "gAAAAAAAD0JAkpEwTNJN7m82wRRXatd_GjY0Ii2Q4fDSpto5pCgUBffW1hgQr8JwKY4jkO37cLXTVHwh95a76fqbSn7rfHjjNCuGnT99Rdm3md-iqn7D7QE=", + "provider-072": "gAAAAAAAD0JABDTb_d5tRZ2MCVDGKFLFlYdNvgV5WgxUQfubYRoXj_2spZ1IJY0nA9jnOK5_kMqtt8AAk8MowT_O1Gl7wraQozij002mHay_0KpByQZyH1Y=", + "provider-073": "gAAAAAAAD0JA35Hype3h5YrRm1DP04j9d3NcBtSv5DyE8d9-plL17gV6xXa9e3vrIpMNhPLYckCHULbeq_mTGXqpHCITGkyteT2fn8WVLOaFEZ7KxJrZ1ao=", + "provider-074": "gAAAAAAAD0JA53v2Tla3kX3nIaUsaWqdVmKe41U5UCUolouSSv2LqyZZb9l_P5J53ZEOlQC49ZjglwL4fvjmvkg6RyZfAVtITmX0RXIkykzvEvTyIlvVV-c=", + "provider-075": "gAAAAAAAD0JA4bc7magJ5p4ftSOFjt74xAQJlw0_Kr7WyLr25Rj1i8LeBbZTKJ3erKZXZwbLwpN92FObMHHOUbxqq0qKp4Kmvw4wF5C3Bm_LCVD_TKX9DxQ=", + "provider-076": "gAAAAAAAD0JAkVGEJLIdj7cJxMetMzxYt4NrmCLRys28zCeeorUOfcuiqNNImRShzYc9lLtf7w7YoZOhueQ5it2xcCev1DV4RDFJM2tKPjnXroIGYAFbhUw=", + "provider-077": "gAAAAAAAD0JAsQsksP6Z1_YfPix6LBNiILF-y1-fWlku-3wl7hq0DwEDiqr8D-yu3CLfkcVU7E5j9UHbRE2RqT9Ac7Pm2f-Q9Uiliwx1fLK3uFmJuiyrM3c=", + "provider-078": "gAAAAAAAD0JAhhjIEFvZS6kF3IUaQqn1x0aLrOgRcPdcM27tsIXyB7vwZSgqVk7VZIYIr60mupRQD8q1vIo66wFRyPcOO_TdFzUjZXGPX5FvQNpn6s8Vbwo=", + "provider-079": "gAAAAAAAD0JAe94_XDzvvJyJUTqft6Gf7mrpJzmbh9_9X6vdihLktm_0IbuBgm-8qtaMjAlkAp8Wsp3OC-gKSHcQu9ta60N-NUSFCOKIIjvgZbNpB3JNNdk=", + "provider-080": "gAAAAAAAD0JAWYtwtu1cbp3XlEDWjZlBp8LQQ9uO3wFLsdxh60ndemLs3Qmw-2-U2IYSweFqEsAtIYKeNwIofxDq2yAYPCGeg23KtmfWN3Jc_3tpL3GPO1I=", + "provider-081": "gAAAAAAAD0JA1q0GlZjz1qSiM9-S4SNQj_s0ean9rQiKWFi0W2Qu7rKATAmGaZV3J5LQGgfXL3s7K1atVirbQ9mbVRcp2-Nh-yfnTt2nEIE8dsrqaU7FUrM=", + "provider-082": "gAAAAAAAD0JAS4i0A_rFY6SkN1QLpo9wtSNrYWl9u5_9rx7Pv4xHJSPcSs8313mcpNXOponmpVSPKJih3xuOlinBAGPT-WkumY_JLNOMGmu5Hcc6k-UOuVc=", + "provider-083": "gAAAAAAAD0JArrVSvJmJ-9liaD5_Npm_jEJqIXUjTGeWEIkQSjDq1V8TpVSr4b3Guz2N0KGDVZCUpZImgXvsCSY73pCOuMShj5bCi8YBPsf1JgsamYFU_Z4=", + "provider-084": "gAAAAAAAD0JArCrbHbZ_7W51T9WFIsYIYLsNF3ihbs-5DOEKbBLDgbw9aAcfVa1aCwnNESwg8OBABvpKr-5F4Zph_FcGG1LpHd2Ye2pDxx5Ul4Z4Ql6em2w=", + "provider-085": "gAAAAAAAD0JAjlher3s8linCmF_bpTSoQOb-LT3zZAiwd-3T9xMlItGCe6V_R2nt_j8pS2DHIEbLuLriLGwCuVCFDwPPqxODzsRXA9R8goVsH--XMWIDx54=", + "provider-086": "gAAAAAAAD0JAOvWWo66DTyKorw6xougky5D3lk5TiyxXWV-s6Qtsbk1YjCC10Xhk67HExMdcoW9UoeDZLIoqhUBzmNsShiKt9YufI4YYSfPm9aL0gXx2Iug=", + "provider-087": "gAAAAAAAD0JAYyndje5fZAtH9g3nDmE8kp0ZBNTdF6TLgMsErqzInyZRoL3kEVovX26gEgpdBvDJ1-5KvO3dMoXl4ah6scY6O6USnNnKSDDa0CgE9rFG5JU=", + "provider-088": "gAAAAAAAD0JAs40wY8C--WZ9TPF2RzD2Z0riPKgAecv3tBuZ0EEhDrkfST5bz3LJk1_jFUDEsR1BCQYQ_jH_j_AFgjACZnXMAdQcXPKKMei6dpCta_X0dtE=", + "provider-089": "gAAAAAAAD0JA3YClZN2rmHNhTljyP7NOFxdP__GKswG8yDWM-sXwzkcZKxF6FqeZTlISPMtYz5oh_-cpztajYq9ki3kPh4nnGyQZ_MqLq1WRPAt80-YU8Lw=", + "provider-090": "gAAAAAAAD0JAZKyzODbB7olM9rwwSDX6H9a56GtE7hwZuSsKmiS02WUop6w2Lymg-Xm8thwmMnLF-1j5KpGCBo2nH4Xd5x2DuBY35udDRoNobN5BC6-f30Y=", + "provider-091": "gAAAAAAAD0JAD6XKTSgi3huiv0PWsoNF7yH30KDzs0f2cHwW90NRT3nxTjsU6XDVzBrh57vd9kV8ofV-C4Qemf5tpbLirhD1aW9jhdvVfckhTrUQqEitvLo=", + "provider-092": "gAAAAAAAD0JAr8ekPqcoNB4KEjo_gfaoOCcvvpD3ykweN5VxFj_EBckVswLb5KicTwlN7a_a8pJQBbJrsps-mLDDBkxau_EwGSyWxYmVVzaPRbqnCejWaXw=", + "provider-093": "gAAAAAAAD0JAp0KUmZFCNpxy45Y3yKJP2-6ovo8keTVJjKVblQbU_1GSc2YgZcaMMQoHxSz9VMXz-gSuAOhW6fexPyN5QH7JqZIzfrQsIA7XebXo29A3ocI=", + "provider-094": "gAAAAAAAD0JAhBwwkb_yZHrQ2UsRekxEESVaKk1xh46yHJRCO5VMew0SaUy3Sk1HandiQkMXmyHc95leNkRBNcu3vRv7_AIo5hCETlm_wub5aZs3QNuuncw=", + "provider-095": "gAAAAAAAD0JAZASXeJl8WY0k4yT0A4TJQkO_YrGSN-LQEo-_Q6D33v-CXhxPFIUq_AEKlEbnBWgriqRQ5bFC8UtxCvYvJNzV9GIVassojICamFgAPUzJIYg=", + "provider-096": "gAAAAAAAD0JArGgydkoNqqplDShZoTP9VyPRvWDSgLy6lXuAwZPzt-i6E_Xalu5Ww73V4ca6gA6TptseKv35k2p2RZts_LyiWKY4ih_PqjGlrqVbydtazf0=", + "provider-097": "gAAAAAAAD0JASCJxfWQ4irHGf5QUq27tti1CLKUKvxN-wZ5KL5SobwK9s2_tlWbpnnFSwxTWyAr_3PI2PeLfB1WortkvnFtjL1u9V5eaZCXn3khRy14_9oI=", + "provider-098": "gAAAAAAAD0JAZ-K7BGACvTdqfMSRbFcOxaDw-o-TDEN3sIOLrnhePkhFitxThopo2WyRoFUGtOJdWW_U7XMvM_K9-1K-zC8bYphlZOh92rHO0HRyaJ40qMY=", + "provider-099": "gAAAAAAAD0JA6tr96Hbphykg8LKZI4jbIatMSbpVtFZYQtcl4arnshQm2AEu2Z0hblzM8OSvHQWHgl25XItM8XdabZPXRfCD8wVdGI52t-yh4XVCzMQkV7U=" + } +} diff --git a/frontend/src/components/common/TitleBar.vue b/frontend/src/components/common/TitleBar.vue index da5819a..175afb5 100644 --- a/frontend/src/components/common/TitleBar.vue +++ b/frontend/src/components/common/TitleBar.vue @@ -34,7 +34,7 @@ const pageTitle = computed(() => { logs: t('运行日志', 'Operation Logs'), media: t('音视频转写', 'Media Transcription'), } - return titles[name] || 'NotesAgent' + return titles[name] || 'OpenNexus' }) const currentFileName = computed(() => { @@ -63,7 +63,7 @@ function toggleFromTitlebar(event: MouseEvent) {
- NotesAgent + OpenNexus
- +
- +
diff --git a/frontend/src/features/media/MediaView.vue b/frontend/src/features/media/MediaView.vue index e1ecf72..eff0eb6 100644 --- a/frontend/src/features/media/MediaView.vue +++ b/frontend/src/features/media/MediaView.vue @@ -2,17 +2,36 @@ import ActionDialog from '@/components/common/ActionDialog.vue' import { useActionDialog } from '@/composables/useActionDialog' const { actionDialog, resolveAction, askConfirm } = useActionDialog() -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { apiClient } from '@/services/apiClient' +import { isDesktop } from '@/services/platform/desktop' import { useRoute } from 'vue-router' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { localeTag, t } from '@/i18n' import FilePicker from '@/components/common/FilePicker.vue' const route = useRoute() +const maxUploadMiB = isDesktop() ? 64 : 128 const submission = createMediaSubmission() const updateExisting = ref(false) const jobs = ref([]) const selected = ref(null) +const audioSource = ref('') +watch(() => selected.value?.attachment_id, async (id, _old, onCleanup) => { + let stale = false + let objectUrl: string | undefined + onCleanup(() => { stale = true; if (objectUrl) URL.revokeObjectURL(objectUrl) }) + audioSource.value = '' + if (!id) return + if (!isDesktop()) { audioSource.value = mediaService.audio(id); return } + try { + const response = await apiClient.get(`/api/media/attachments/${encodeURIComponent(id)}`) + const blob = await response.blob() + if (stale) return + objectUrl = URL.createObjectURL(blob) + audioSource.value = objectUrl + } catch (e) { if (!stale) error.value = (e as Error).message } +}) const file = ref(null) const reference = ref(null) const matchResult = ref('') @@ -62,7 +81,7 @@ async function action(work: () => Promise) { async function submit() { if (!file.value) return await action(async () => { - if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.')) + if (file.value!.size > maxUploadMiB * 1024 * 1024) throw new Error(t(`文件不能超过 ${maxUploadMiB} MiB。`, `Files cannot exceed ${maxUploadMiB} MiB.`)) if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.')) let terms = {} if (terminology.value.trim()) { @@ -119,7 +138,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })

{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}

-

{{ t('音视频转写', 'Media Transcription') }}

{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}

+

{{ t('音视频转写', 'Media Transcription') }}

{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}

{{ notice }}

@@ -141,7 +160,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })

{{ labels[selected.status] }}

{{ t('修订', 'Revision') }} {{ selected.revision }}
-