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