feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力

This commit is contained in:
2026-09-08 12:23:20 +08:00
parent 06e46c3ad1
commit fec89f9a2f
55 changed files with 4197 additions and 93 deletions
+4 -2
View File
@@ -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 CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
OpenNexus 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 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 尚未接入。
> 正式名称:OpenNexus2026-09-08)。旧应用标识 `cc.kronecker.notesagent`、数据库/凭据路径和协议标识保留兼容,不因品牌更名创建新数据目录。
## 目录
```text
+1 -1
View File
@@ -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"),
+4 -3
View File
@@ -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(
+67
View File
@@ -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
+65 -6
View File
@@ -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():
+2
View File
@@ -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()):
+151
View File
@@ -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())
+3
View File
@@ -25,6 +25,9 @@ dependencies = [
dev = [
"pytest>=8.4,<9.0",
]
packaging = [
"pyinstaller>=6.16,<7",
]
[tool.pytest.ini_options]
pythonpath = ["."]
+5
View File
@@ -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())
+1 -1
View File
@@ -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"
+13
View File
@@ -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")
+107
View File
@@ -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()
+92
View File
@@ -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"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171717" />
<title>NotesAgent</title>
<title>OpenNexus</title>
</head>
<body>
<div id="app"></div>
+1079 -17
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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
+20
View File
@@ -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",
@@ -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",
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
+471
View File
@@ -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<T> = std::result::Result<T, String>;
pub type Broker = Arc<dyn Fn(&serde_json::Value) -> Result<serde_json::Value> + 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<String, String>,
}
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<String, String>,
) -> 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<String> {
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<Vec<u8>> {
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<u16> {
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::<Sha256>::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<String> {
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<Mutex<Option<ChildStdin>>>,
secret: Zeroizing<String>,
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<String>,
working_dir: PathBuf,
data_dir: PathBuf,
session: Option<Session>,
attempts: VecDeque<Instant>,
next_attempt: Option<Instant>,
broker: Option<Broker>,
bundle_manifest: Option<String>,
}
/// Host-only request context; deliberately neither Serialize nor Debug.
pub struct RequestSession {
pub url: String,
pub authorization: Zeroizing<String>,
pub generation: String,
}
impl CoreSupervisor {
pub fn new(
executable: PathBuf,
arguments: Vec<String>,
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<RequestSession> {
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<Broker>,
) -> Result<Session> {
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::<serde_json::Value>(&line) else {
break;
};
if message.get("rpc").is_none() {
let _ = tx.send(Ok::<Vec<u8>, 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::<Sha256>::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());
}
}
+710
View File
@@ -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<T> = std::result::Result<T, String>;
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<Vec<u8>> {
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<Vec<u8>>,
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<Self> {
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<ChaCha20Poly1305> {
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> {
KeyProvider::try_from(Zeroizing::new(self.key.to_vec()))
.map_err(|_| "CREDENTIAL_KDF_FAILED".into())
}
fn store(&self) -> Result<iota_stronghold::Store> {
self.stronghold
.get_client(CLIENT)
.map(|c| c.store())
.map_err(|_| "CREDENTIAL_STORE_FAILED".into())
}
fn read(&self, key: &[u8]) -> Result<Option<Zeroizing<Vec<u8>>>> {
let Some(data) = self
.store()?
.get(key)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
else {
return Ok(None);
};
if data.len() < 28 {
return Err("CREDENTIAL_STORE_CORRUPT".into());
}
self.cipher()?
.decrypt(
Nonce::from_slice(&data[..12]),
Payload {
msg: &data[12..],
aad: key,
},
)
.map(Zeroizing::new)
.map(Some)
.map_err(|_| "CREDENTIAL_STORE_CORRUPT".into())
}
fn write(&self, key: Vec<u8>, value: &[u8]) -> Result<()> {
if value.is_empty() || value.len() > 65536 {
return Err("CREDENTIAL_VALUE_INVALID".into());
}
let mut nonce = [0u8; 12];
rand::rngs::OsRng
.try_fill_bytes(&mut nonce)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let ciphertext = self
.cipher()?
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: value,
aad: &key,
},
)
.map_err(|_| "CREDENTIAL_CIPHER_FAILED")?;
let mut record = nonce.to_vec();
record.extend(ciphertext);
self.store()?
.insert(key, record, None)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
Ok(())
}
fn persist(&self, path: &Path) -> Result<()> {
let parent = path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let staging = tempfile::tempdir_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let snapshot = staging.path().join("snapshot");
self.stronghold
.write_client(CLIENT)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
self.stronghold
.commit_with_keyprovider(&SnapshotPath::from_path(&snapshot), &self.provider()?)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
let bytes = fs::read(&snapshot).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let mut target =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
target
.write_all(MAGIC)
.and_then(|_| target.write_all(&self.salt))
.and_then(|_| target.write_all(&bytes))
.and_then(|_| target.as_file().sync_all())
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
target.persist(path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
Ok(())
}
}
pub struct CredentialBroker {
path: PathBuf,
unlocked: Option<Unlocked>,
}
impl CredentialBroker {
/// Source comes from the native file picker, never a raw WebView path.
/// Import is idempotent; conflicting IDs stop the entire transaction.
pub fn import_fernet(
&mut self,
directory: &Path,
environment_key: Option<Zeroizing<String>>,
) -> Result<usize> {
use fs2::FileExt;
let directory = directory
.canonicalize()
.map_err(|_| "MIGRATION_SOURCE_INVALID")?;
let source = directory.join("credentials.json");
let key_path = directory.join("master.key");
if !fs::symlink_metadata(&source)
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
.is_file()
{
return Err("MIGRATION_SOURCE_INVALID".into());
}
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(directory.join(".migration.lock"))
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
lock.try_lock_exclusive()
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
if fs::metadata(&source)
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
.len()
> MAX_FILE
{
return Err("MIGRATION_SOURCE_INVALID".into());
}
let source_bytes = fs::read(&source).map_err(|_| "MIGRATION_SOURCE_INVALID")?;
let source_hash = format!("{:x}", Sha256::digest(&source_bytes));
let tokens: BTreeMap<String, String> =
serde_json::from_slice(&source_bytes).map_err(|_| "MIGRATION_SOURCE_INVALID")?;
if tokens.len() > 10000 {
return Err("MIGRATION_SOURCE_INVALID".into());
}
let local_key = if environment_key.is_none() {
if !fs::symlink_metadata(&key_path)
.map_err(|_| "MIGRATION_KEY_MISSING")?
.is_file()
{
return Err("MIGRATION_KEY_MISSING".into());
}
Some(Zeroizing::new(
fs::read_to_string(&key_path).map_err(|_| "MIGRATION_KEY_MISSING")?,
))
} else {
None
};
let key = environment_key
.as_ref()
.or(local_key.as_ref())
.ok_or("MIGRATION_KEY_MISSING")?;
let fernet =
Zeroizing::new(fernet::Fernet::new(key.trim()).ok_or("MIGRATION_KEY_INVALID")?);
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let mut decoded = Vec::new();
for (id, token) in &tokens {
let key = CredentialId::legacy(id).key()?;
let value = Zeroizing::new(
fernet
.decrypt(token)
.map_err(|_| "MIGRATION_DECRYPT_FAILED")?,
);
if value.is_empty() || value.len() > 65536 || std::str::from_utf8(&value).is_err() {
return Err("MIGRATION_VALUE_INVALID".into());
}
if let Some(existing) = session.read(&key)? {
if existing.as_slice() != value.as_slice() {
return Err("MIGRATION_CONFLICT".into());
}
}
decoded.push((key, value));
}
let migration_id = format!(
"{:x}",
Sha256::digest(directory.to_string_lossy().as_bytes())
);
let backup = self
.path
.parent()
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("migration-backups")
.join(&migration_id);
fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
// Backups contain ciphertext; the legacy key is sealed under the already
// unlocked device key, rather than adding another plaintext master.key.
let mut nonce = [0u8; 12];
rand::rngs::OsRng
.try_fill_bytes(&mut nonce)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let sealed_key = session
.cipher()?
.encrypt(Nonce::from_slice(&nonce), key.as_bytes())
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
let mut key_backup = b"ONXFBK1".to_vec();
key_backup.extend(session.salt);
key_backup.extend(nonce);
key_backup.extend(sealed_key);
for (name, bytes) in [
("credentials.json", source_bytes.as_slice()),
("master-key.sealed", key_backup.as_slice()),
] {
let mut temporary =
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
temporary
.write_all(bytes)
.and_then(|_| temporary.as_file().sync_all())
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
temporary
.persist(backup.join(name))
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
}
let result = (|| {
for (key, value) in &decoded {
session.write(key.clone(), value)?;
}
session.persist(&self.path)?;
// Re-open the committed Stronghold snapshot, not the in-memory cache.
let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let mut temporary =
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
temporary
.write_all(&envelope[40..])
.map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let verified = Unlocked {
stronghold: Stronghold::default(),
key: Zeroizing::new(session.key.to_vec()),
salt: session.salt,
};
verified
.stronghold
.load_client_from_snapshot(
CLIENT,
&verified.provider()?,
&SnapshotPath::from_path(temporary.path()),
)
.map_err(|_| "MIGRATION_VERIFY_FAILED")?;
for (key, value) in &decoded {
if verified.read(key)?.as_deref().map(|v| v.as_slice()) != Some(value.as_slice()) {
return Err("MIGRATION_VERIFY_FAILED".into());
}
}
if fs::read(&source).map_err(|_| "MIGRATION_SOURCE_CHANGED")? != source_bytes {
return Err("MIGRATION_SOURCE_CHANGED".into());
}
let marker = serde_json::json!({"schema":1,"owner":"OpenNexus","state":"switched","source_sha256":source_hash,"count":decoded.len(),"environment_key":environment_key.is_some()});
let bytes = serde_json::to_vec(&marker).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let mut marker_file = tempfile::NamedTempFile::new_in(&directory)
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
marker_file
.write_all(&bytes)
.and_then(|_| marker_file.as_file().sync_all())
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
marker_file
.persist(directory.join(".opennexus-owner.json"))
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
Ok(decoded.len())
})();
if result.is_err() {
self.lock();
}
result
}
pub fn dispatch(&mut self, request: &serde_json::Value) -> Result<serde_json::Value> {
let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?;
let params = &request["params"];
if method == "credentials.delete_many" || method == "credentials.move_many" {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let result = (|| {
let mut removed = Vec::new();
if method.ends_with("delete_many") {
let ids = params["ids"].as_array().ok_or("HOST_REQUEST_INVALID")?;
let keys: Vec<_> = ids
.iter()
.map(|id| {
let id = id.as_str().ok_or("HOST_REQUEST_INVALID")?;
Ok((id.to_string(), CredentialId::legacy(id).key()?))
})
.collect::<Result<_>>()?;
for (id, key) in keys {
if session
.store()?
.delete(&key)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
.is_some()
{
removed.push(id);
}
}
} else {
let replacements = params["replacements"]
.as_object()
.ok_or("HOST_REQUEST_INVALID")?;
let mut moves = Vec::new();
for (old, new) in replacements {
let source = CredentialId::legacy(old);
let target =
CredentialId::legacy(new.as_str().ok_or("HOST_REQUEST_INVALID")?);
// ID migrations cannot change Provider/Plugin/MCP families.
if std::mem::discriminant(&source.scope)
!= std::mem::discriminant(&target.scope)
{
return Err("CREDENTIAL_SCOPE_DENIED".into());
}
let source_key = source.key()?;
let target_key = target.key()?;
if let Some(value) = session.read(&source_key)? {
if let Some(existing) = session.read(&target_key)? {
if existing.as_slice() != value.as_slice() {
return Err("MIGRATION_CONFLICT".into());
}
}
moves.push((source_key, target_key, value));
}
}
// Reject cycles/overlapping source+destination rather than deleting
// a newly written value midway through a multi-ID migration.
if moves.iter().any(|(old, new, _)| {
old != new && moves.iter().any(|(source, _, _)| source == new)
}) {
return Err("MIGRATION_CONFLICT".into());
}
for (old, new, value) in moves {
session.write(new.clone(), &value)?;
if old != new {
session
.store()?
.delete(&old)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
}
}
}
session.persist(&self.path)?;
Ok(serde_json::json!(removed))
})();
if result.is_err() {
self.lock();
}
return result;
}
let id = CredentialId::legacy(params["id"].as_str().ok_or("HOST_REQUEST_INVALID")?);
match method {
"credentials.resolve" => self
.resolve(&id.scope, &id)?
.map(|v| {
String::from_utf8(v.to_vec())
.map(serde_json::Value::String)
.map_err(|_| "CREDENTIAL_ENCODING_INVALID".into())
})
.unwrap_or(Ok(serde_json::Value::Null)),
"credentials.has" => Ok(serde_json::json!(self.resolve(&id.scope, &id)?.is_some())),
"credentials.put" => {
let value = params["secret"].as_str().ok_or("HOST_REQUEST_INVALID")?;
self.put(&id, Zeroizing::new(value.as_bytes().to_vec()))?;
Ok(serde_json::Value::Null)
}
"credentials.delete" => {
let existed = self.resolve(&id.scope, &id)?.is_some();
self.delete(&id)?;
Ok(serde_json::json!(existed))
}
_ => Err("HOST_METHOD_DENIED".into()),
}
}
pub fn new(path: PathBuf) -> Self {
Self {
path,
unlocked: None,
}
}
pub fn is_locked(&self) -> bool {
self.unlocked.is_none()
}
pub fn lock(&mut self) {
self.unlocked.take();
}
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
self.lock();
let session = if self.path.exists() {
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if !metadata.is_file() || metadata.len() > MAX_FILE {
return Err("CREDENTIAL_STORE_CORRUPT".into());
}
let data = fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if data.len() < 40 || &data[..8] != MAGIC {
return Err("SCHEMA_INCOMPATIBLE".into());
}
let mut salt = [0u8; 32];
salt.copy_from_slice(&data[8..40]);
let session = Unlocked::derive(&password, salt)?;
let mut temp = tempfile::NamedTempFile::new_in(
self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?,
)
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
temp.write_all(&data[40..])
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
session
.stronghold
.load_client_from_snapshot(
CLIENT,
&session.provider()?,
&SnapshotPath::from_path(temp.path()),
)
.map_err(|_| "CREDENTIAL_UNLOCK_FAILED")?;
session
} else {
let mut salt = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut salt)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let session = Unlocked::derive(&password, salt)?;
session
.stronghold
.create_client(CLIENT)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
session.persist(&self.path)?;
session
};
self.unlocked = Some(session);
Ok(())
}
pub fn list(&self) -> Result<Vec<CredentialId>> {
self.unlocked
.as_ref()
.ok_or("CREDENTIALS_LOCKED")?
.store()?
.keys()
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
.iter()
.map(|key| serde_json::from_slice(key).map_err(|_| "CREDENTIAL_STORE_CORRUPT".into()))
.collect()
}
pub fn put(&mut self, id: &CredentialId, value: Zeroizing<Vec<u8>>) -> Result<()> {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let result = session
.write(id.key()?, &value)
.and_then(|_| session.persist(&self.path));
if result.is_err() {
self.lock();
} // Never serve uncommitted memory after disk failure.
result
}
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
session
.store()?
.delete(&id.key()?)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
let result = session.persist(&self.path);
if result.is_err() {
self.lock();
}
result
}
/// Internal consumers must supply the scope established by the Host dispatcher.
/// This method must never be registered as a Tauri command.
pub fn resolve(&self, caller: &Scope, id: &CredentialId) -> Result<Option<Zeroizing<Vec<u8>>>> {
if caller != &id.scope {
return Err("CREDENTIAL_SCOPE_DENIED".into());
}
self.unlocked
.as_ref()
.ok_or("CREDENTIALS_LOCKED")?
.read(&id.key()?)
}
pub fn change_password(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
let previous = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let mut salt = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut salt)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let next = Unlocked::derive(&password, salt)?;
next.stronghold
.create_client(CLIENT)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
for key in previous
.store()?
.keys()
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
{
let value = previous.read(&key)?.ok_or("CREDENTIAL_STORE_CORRUPT")?;
next.write(key, &value)?;
}
next.persist(&self.path)?;
self.unlocked = Some(next);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"test-only-password-123".to_vec())
}
#[test]
fn python_fernet_migration_is_verified_idempotent_and_preserves_sources() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
let temp = tempfile::tempdir().unwrap();
let old = temp.path().join("legacy");
fs::create_dir(&old).unwrap();
let source = serde_json::to_vec(&fixture["tokens"]).unwrap();
fs::write(old.join("credentials.json"), &source).unwrap();
fs::write(old.join("master.key"), fixture["key"].as_str().unwrap()).unwrap();
let mut broker = CredentialBroker::new(temp.path().join("new/stronghold.v1"));
broker.unlock(password()).unwrap();
assert_eq!(broker.import_fernet(&old, None).unwrap(), 100);
assert_eq!(broker.import_fernet(&old, None).unwrap(), 100);
assert_eq!(broker.list().unwrap().len(), 100);
assert_eq!(fs::read(old.join("credentials.json")).unwrap(), source);
assert!(old.join("master.key").is_file());
broker.lock();
broker.unlock(password()).unwrap();
for (id, value) in fixture["values"].as_object().unwrap() {
assert_eq!(
broker
.resolve(&Scope::Provider, &CredentialId::legacy(id))
.unwrap()
.unwrap()
.as_slice(),
value.as_str().unwrap().as_bytes()
);
}
broker
.put(
&CredentialId::legacy("provider-000"),
Zeroizing::new(b"changed-new-value".to_vec()),
)
.unwrap();
assert_eq!(
broker.import_fernet(&old, None).unwrap_err(),
"MIGRATION_CONFLICT"
);
assert_eq!(
broker
.resolve(&Scope::Provider, &CredentialId::legacy("provider-000"))
.unwrap()
.unwrap()
.as_slice(),
b"changed-new-value"
);
}
#[test]
fn stronghold_roundtrip_scope_lock_and_password_rotation() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("credentials.v1");
let mut broker = CredentialBroker::new(file.clone());
broker.unlock(password()).unwrap();
let id = CredentialId {
scope: Scope::Provider,
id: "provider-one".into(),
};
broker
.put(&id, Zeroizing::new(b"fixture-secret-do-not-log".to_vec()))
.unwrap();
assert!(broker.resolve(&Scope::Mcp("x".into()), &id).is_err());
assert!(!fs::read(&file)
.unwrap()
.windows(b"fixture-secret-do-not-log".len())
.any(|w| w == b"fixture-secret-do-not-log"));
broker.lock();
assert_eq!(
broker.resolve(&Scope::Provider, &id).unwrap_err(),
"CREDENTIALS_LOCKED"
);
broker.unlock(password()).unwrap();
assert_eq!(
broker
.resolve(&Scope::Provider, &id)
.unwrap()
.unwrap()
.as_slice(),
b"fixture-secret-do-not-log"
);
broker
.change_password(Zeroizing::new(b"second-test-password".to_vec()))
.unwrap();
broker.lock();
assert!(broker.unlock(password()).is_err());
broker
.unlock(Zeroizing::new(b"second-test-password".to_vec()))
.unwrap();
assert_eq!(broker.list().unwrap().len(), 1);
broker.delete(&id).unwrap();
assert!(broker.resolve(&Scope::Provider, &id).unwrap().is_none());
}
#[test]
fn corrupt_store_is_not_recreated() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("credentials.v1");
fs::write(&path, b"corrupt").unwrap();
let mut broker = CredentialBroker::new(path.clone());
assert!(broker.unlock(password()).is_err());
assert_eq!(fs::read(path).unwrap(), b"corrupt");
}
}
+3
View File
@@ -1,4 +1,7 @@
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
pub mod core;
pub mod credentials;
pub mod recent;
mod runtime_compat;
pub mod workspace;
+329 -21
View File
@@ -3,17 +3,24 @@
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use notesagent_host::core::CoreSupervisor;
use notesagent_host::credentials::CredentialBroker;
use notesagent_host::recent::{RecentVault, RecentVaultStore};
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{Emitter, Manager, State};
use zeroize::Zeroizing;
#[derive(Default)]
struct Host {
workspace: Mutex<Option<Workspace>>,
recent: Mutex<Option<RecentVaultStore>>,
core: Arc<Mutex<Option<CoreSupervisor>>>,
credentials: Arc<Mutex<Option<CredentialBroker>>>,
streams: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
}
fn info(ws: &Workspace) -> RecentVault {
@@ -39,8 +46,14 @@ fn with_workspace<T>(
}
#[tauri::command]
fn host_capabilities() -> serde_json::Value {
serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"})
fn host_capabilities(host: State<'_, Host>) -> serde_json::Value {
let ready = host
.core
.try_lock()
.ok()
.and_then(|mut core| core.as_mut().map(|c| c.available()))
.unwrap_or(false);
serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":false,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"})
}
#[derive(serde::Serialize)]
@@ -65,14 +78,9 @@ fn is_json_content_type(content_type: &str) -> bool {
media_type == "application/json" || media_type.ends_with("+json")
}
#[cfg(test)]
fn core_url(path: &str) -> Result<String, String> {
if (!path.starts_with("/api/") && path != "/api" && path != "/health")
|| path.contains("..")
|| path.contains(['\r', '\n'])
{
return Err("CORE_PATH_DENIED".into());
}
Ok(format!("http://127.0.0.1:8000{path}"))
notesagent_host::core::checked_url(8000, path)
}
#[cfg(test)]
@@ -108,14 +116,28 @@ mod core_proxy_tests {
}
}
/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。
/// Authenticated process-local transport; session headers are owned by Rust.
#[tauri::command]
async fn core_request(
method: String,
path: String,
body: Option<serde_json::Value>,
authorization: Option<String>,
body_base64: Option<String>,
content_type: Option<String>,
idempotency_key: Option<String>,
host: State<'_, Host>,
) -> Result<CoreResponse, String> {
let core = host.core.clone();
let core_path = path.clone();
let session = tauri::async_runtime::spawn_blocking(move || {
core.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("CORE_UNAVAILABLE")?
.request_session(&core_path)
})
.await
.map_err(|_| "CORE_UNAVAILABLE")??;
let method =
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
if !matches!(
@@ -130,16 +152,47 @@ async fn core_request(
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|_| "CORE_CLIENT_ERROR")?;
let mut request = client.request(method, core_url(&path)?);
let mut request = client
.request(method, &session.url)
.header(
reqwest::header::AUTHORIZATION,
session.authorization.as_str(),
)
.header("X-Core-Generation", &session.generation);
if let Some(value) = body {
request = request.json(&value);
}
if let Some(value) = authorization {
request = request.header(reqwest::header::AUTHORIZATION, value);
if let Some(encoded) = body_base64 {
if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let bytes = BASE64_STANDARD
.decode(encoded)
.map_err(|_| "CORE_BODY_INVALID")?;
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let content_type = content_type
.as_deref()
.unwrap_or("application/octet-stream");
if !matches!(content_type, "application/octet-stream" | "application/zip") {
return Err("CORE_CONTENT_TYPE_DENIED".into());
}
request = request
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(bytes);
}
let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
if let Some(key) = idempotency_key {
if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("CORE_HEADER_INVALID".into());
}
request = request.header("Idempotency-Key", key);
}
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
let status = response.status().as_u16();
let content_type = response
.headers()
@@ -153,9 +206,12 @@ async fn core_request(
{
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?;
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_RESPONSE_TOO_LARGE".into());
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
bytes.extend_from_slice(&chunk);
}
let (body, body_base64) = if is_json_content_type(&content_type) {
(
@@ -173,6 +229,182 @@ async fn core_request(
})
}
#[tauri::command]
fn core_stream_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> {
if let Some(task) = host
.streams
.lock()
.map_err(|_| "HOST_BUSY")?
.remove(&request_id)
{
task.abort();
}
Ok(())
}
#[tauri::command]
fn core_stream(
host: State<'_, Host>,
request_id: String,
path: String,
method: String,
body: Option<serde_json::Value>,
last_event_id: Option<String>,
channel: tauri::ipc::Channel<serde_json::Value>,
) -> Result<(), String> {
uuid::Uuid::parse_str(&request_id).map_err(|_| "CORE_REQUEST_ID_INVALID")?;
if !matches!(method.as_str(), "GET" | "POST") {
return Err("CORE_METHOD_DENIED".into());
}
if body
.as_ref()
.is_some_and(|b| b.to_string().len() > 1024 * 1024)
{
return Err("CORE_REQUEST_TOO_LARGE".into());
}
if last_event_id
.as_ref()
.is_some_and(|s| s.len() > 128 || s.contains(['\r', '\n']))
{
return Err("CORE_HEADER_INVALID".into());
}
let core = host.core.clone();
let streams = host.streams.clone();
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
if running.len() >= 16 || running.contains_key(&request_id) {
return Err("CORE_STREAM_LIMIT".into());
}
let id = request_id.clone();
let task = tauri::async_runtime::spawn(async move {
let result: Result<(), String> = async {
let session = tauri::async_runtime::spawn_blocking(move || {
core.lock().map_err(|_| "HOST_BUSY")?.as_mut().ok_or("CORE_UNAVAILABLE")?.request_session(&path)
}).await.map_err(|_| "CORE_UNAVAILABLE")??;
let client = reqwest::Client::builder().no_proxy()
.redirect(reqwest::redirect::Policy::none()).timeout(Duration::from_secs(600))
.build().map_err(|_| "CORE_CLIENT_ERROR")?;
let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url)
.header("Authorization", session.authorization.as_str())
.header("X-Core-Generation", session.generation).header("Accept", "text/event-stream");
if let Some(body) = body { request = request.json(&body); }
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
channel.send(serde_json::json!({"kind":"headers","status":response.status().as_u16()})).map_err(|_| "CORE_STREAM_CLOSED")?;
let mut size = 0usize;
while let Some(bytes) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
size = size.saturating_add(bytes.len());
if size > MAX_CORE_RESPONSE_BYTES { return Err("CORE_RESPONSE_TOO_LARGE".into()); }
for chunk in bytes.chunks(16384) {
channel.send(serde_json::json!({"kind":"chunk","data":BASE64_STANDARD.encode(chunk)})).map_err(|_| "CORE_STREAM_CLOSED")?;
}
}
Ok(())
}.await;
match result {
Ok(()) => {
let _ = channel.send(serde_json::json!({"kind":"done"}));
}
Err(code) => {
let _ = channel.send(serde_json::json!({"kind":"error","code":code}));
}
}
if let Ok(mut running) = streams.lock() {
running.remove(&id);
}
});
running.insert(request_id, task);
Ok(())
}
#[tauri::command]
fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String> {
let broker = host
.credentials
.try_lock()
.map_err(|_| "CREDENTIALS_BUSY")?;
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
Ok(serde_json::json!({"locked":broker.is_locked()}))
}
#[tauri::command]
async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> {
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.unlock(password)
})
.await
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
fn credentials_lock(host: State<'_, Host>) -> Result<(), String> {
host.credentials
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.lock();
Ok(())
}
#[tauri::command]
async fn credentials_import(host: State<'_, Host>) -> Result<Option<usize>, String> {
let Some(path) = rfd::FileDialog::new()
.set_title("选择旧版本的 credentials.json(不会删除原文件)")
.add_filter("Fernet credentials", &["json"])
.pick_file()
else {
return Ok(None);
};
if path.file_name().and_then(|n| n.to_str()) != Some("credentials.json") {
return Err("MIGRATION_SOURCE_INVALID".into());
}
let directory = path
.parent()
.ok_or("MIGRATION_SOURCE_INVALID")?
.to_path_buf();
let broker = host.credentials.clone();
let environment_key = std::env::var("APP_CREDENTIAL_MASTER_KEY")
.ok()
.map(Zeroizing::new);
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.import_fernet(&directory, environment_key)
.map(Some)
})
.await
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
async fn credentials_change_password(
host: State<'_, Host>,
password: String,
) -> Result<(), String> {
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.change_password(password)
})
.await
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
@@ -314,6 +546,65 @@ fn main() {
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
let credential_state = app.state::<Host>().credentials.clone();
*credential_state
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
));
let data_dir = app.path().app_data_dir()?.join("core-data");
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
let core = if cfg!(debug_assertions) {
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
.canonicalize()?;
let python = backend.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
CoreSupervisor::new(
python,
vec!["-m".into(), "app.sidecar".into()],
backend,
data_dir,
)
} else {
let root = app.path().resource_dir()?.join("core");
CoreSupervisor::new(
root.join(if cfg!(windows) {
"opennexus-core.exe"
} else {
"opennexus-core"
}),
vec![],
root,
data_dir,
)
.with_bundle_manifest(
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
)
};
let core = core.with_broker(Arc::new(move |request| {
credential_state
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.dispatch(request)
}));
*app.state::<Host>()
.core
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(core);
let handle = app.handle().clone();
std::thread::spawn(move || {
if let Ok(mut core) = handle.state::<Host>().core.lock() {
if let Some(core) = core.as_mut() {
let _ = core.start();
}
}
});
Ok(())
})
.on_menu_event(|app, event| {
@@ -330,7 +621,14 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
host_capabilities,
credentials_status,
credentials_unlock,
credentials_lock,
credentials_change_password,
credentials_import,
core_request,
core_stream,
core_stream_cancel,
editor_capabilities,
workspace_choose,
workspace_open,
@@ -343,6 +641,16 @@ fn main() {
workspace_delete,
workspace_mkdir
])
.run(tauri::generate_context!())
.expect("桌面 Host 启动失败");
.build(tauri::generate_context!())
.expect("桌面 Host 启动失败")
.run(|app, event| {
if let tauri::RunEvent::Exit = event {
if let Ok(mut broker) = app.state::<Host>().credentials.lock() {
broker.take();
}
if let Ok(mut core) = app.state::<Host>().core.lock() {
core.take();
}
}
});
}
+39
View File
@@ -0,0 +1,39 @@
//! C23 compatibility for the MSVCRT-based Windows GNU development target.
//! Recent libsodium archives reference memset_explicit, absent in MSVCRT.
//! Volatile stores preserve its non-elidable wipe semantics; MSVC/UCRT release
//! builds use their native runtime and do not compile this compatibility symbol.
#[cfg(all(windows, target_env = "gnu"))]
#[no_mangle]
unsafe extern "C" fn memset_explicit(
destination: *mut std::ffi::c_void,
value: std::ffi::c_int,
count: usize,
) -> *mut std::ffi::c_void {
for offset in 0..count {
// SAFETY: the C ABI caller must supply a writable region of count bytes,
// exactly as for memset. Volatile stores cannot be removed as dead writes.
unsafe {
destination
.cast::<u8>()
.add(offset)
.write_volatile(value as u8);
}
}
std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
destination
}
#[cfg(all(test, windows, target_env = "gnu"))]
mod tests {
#[test]
fn explicit_memset_preserves_surrounding_bytes_and_return_pointer() {
let mut data = [0x55u8; 34];
let pointer = data[1..33].as_mut_ptr().cast();
// SAFETY: the subslice contains exactly 32 writable bytes.
assert_eq!(unsafe { super::memset_explicit(pointer, 0, 32) }, pointer);
assert_eq!(data[0], 0x55);
assert_eq!(data[33], 0x55);
assert!(data[1..33].iter().all(|b| *b == 0));
}
}
@@ -0,0 +1,7 @@
{
"bundle": {
"active": true,
"targets": ["nsis"],
"resources": {"../../.build/sidecar/dist/opennexus-core/": "core/"}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "NotesAgent Preview",
"productName": "OpenNexus",
"version": "0.3.0-alpha.1",
"identifier": "cc.kronecker.notesagent",
"build": {
@@ -10,9 +10,9 @@
"frontendDist": "../dist"
},
"app": {
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
"windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"capabilities": ["main"]
}
},
+21
View File
@@ -0,0 +1,21 @@
use notesagent_host::core::verify_bundle;
#[test]
fn bundle_rejects_modified_missing_and_extra_files() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("opennexus-core.exe");
std::fs::write(&file, b"abc").unwrap();
let manifest = r#"{"protocol":1,"product":"OpenNexus","files":{"opennexus-core.exe":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}}"#;
verify_bundle(dir.path(), manifest).unwrap();
std::fs::write(&file, b"abd").unwrap();
assert_eq!(
verify_bundle(dir.path(), manifest).unwrap_err(),
"CORE_INTEGRITY_FAILED"
);
std::fs::write(&file, b"abc").unwrap();
std::fs::write(dir.path().join("injected.dll"), b"malicious").unwrap();
assert!(verify_bundle(dir.path(), manifest).is_err());
std::fs::remove_file(dir.path().join("injected.dll")).unwrap();
std::fs::remove_file(file).unwrap();
assert!(verify_bundle(dir.path(), manifest).is_err());
}
+123
View File
@@ -0,0 +1,123 @@
//! Executes the real worktree Core, with no personal data or external Provider.
use notesagent_host::core::CoreSupervisor;
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
use std::path::Path;
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
#[test]
fn real_python_core_authenticates_and_rotates_generation() {
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
.canonicalize()
.unwrap();
let python = backend.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
assert!(
python.is_file(),
"Create the isolated backend environment with uv sync --frozen"
);
let temp = tempfile::tempdir().unwrap();
let mut first = CoreSupervisor::new(
python.clone(),
vec!["-m".into(), "app.sidecar".into()],
backend.clone(),
temp.path().join("one"),
);
let request = first.request_session("/health").unwrap();
assert!(first.available());
assert!(request.url.starts_with("http://127.0.0.1:"));
assert!(!request.url.contains(request.authorization.as_str()));
let mut second = CoreSupervisor::new(
python,
vec!["-m".into(), "app.sidecar".into()],
backend,
temp.path().join("two"),
);
let other = second.request_session("/health").unwrap();
assert_ne!(request.generation, other.generation);
assert_ne!(request.authorization.as_str(), other.authorization.as_str());
drop(first);
let endpoint = request
.url
.trim_start_matches("http://")
.trim_end_matches("/health");
assert!(std::net::TcpStream::connect(endpoint).is_err());
}
#[test]
fn real_core_credential_api_uses_host_stronghold_without_plaintext_response() {
use std::io::{Read, Write};
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
.canonicalize()
.unwrap();
let python = backend.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
let temp = tempfile::tempdir().unwrap();
let broker = Arc::new(Mutex::new(CredentialBroker::new(
temp.path().join("stronghold.v1"),
)));
broker
.lock()
.unwrap()
.unlock(Zeroizing::new(b"controlled-fixture-password".to_vec()))
.unwrap();
let handler = broker.clone();
let mut core = CoreSupervisor::new(
python,
vec!["-m".into(), "app.sidecar".into()],
backend,
temp.path().join("core"),
)
.with_broker(Arc::new(move |request| {
handler.lock().unwrap().dispatch(request)
}));
let session = core
.request_session("/api/credentials/fixture-provider")
.unwrap();
let endpoint = session
.url
.trim_start_matches("http://")
.split('/')
.next()
.unwrap();
let body = r#"{"api_key":"fixture-credential-via-host-pipe"}"#;
let mut socket = std::net::TcpStream::connect(endpoint).unwrap();
socket
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.unwrap();
write!(socket, "PUT /api/credentials/fixture-provider HTTP/1.1\r\nHost: {endpoint}\r\nAuthorization: {}\r\nX-Core-Generation: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", session.authorization.as_str(), session.generation, body.len()).unwrap();
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
assert!(
response.starts_with("HTTP/1.1 200"),
"credential API did not succeed"
);
assert!(!response.contains("fixture-credential-via-host-pipe"));
let id = CredentialId {
scope: Scope::Provider,
id: "fixture-provider".into(),
};
assert_eq!(
broker
.lock()
.unwrap()
.resolve(&Scope::Provider, &id)
.unwrap()
.as_deref()
.map(|s| s.as_slice()),
Some(b"fixture-credential-via-host-pipe".as_slice())
);
assert!(!temp.path().join("core/credentials/master.key").exists());
assert!(!temp
.path()
.join("core/credentials/credentials.json")
.exists());
}
+208
View File
@@ -0,0 +1,208 @@
{
"test_only": true,
"key": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
"values": {
"provider-000": "fixture-secret-000",
"provider-001": "fixture-secret-001",
"provider-002": "fixture-secret-002",
"provider-003": "fixture-secret-003",
"provider-004": "fixture-secret-004",
"provider-005": "fixture-secret-005",
"provider-006": "fixture-secret-006",
"provider-007": "fixture-secret-007",
"provider-008": "fixture-secret-008",
"provider-009": "fixture-secret-009",
"provider-010": "fixture-secret-010",
"provider-011": "fixture-secret-011",
"provider-012": "fixture-secret-012",
"provider-013": "fixture-secret-013",
"provider-014": "fixture-secret-014",
"provider-015": "fixture-secret-015",
"provider-016": "fixture-secret-016",
"provider-017": "fixture-secret-017",
"provider-018": "fixture-secret-018",
"provider-019": "fixture-secret-019",
"provider-020": "fixture-secret-020",
"provider-021": "fixture-secret-021",
"provider-022": "fixture-secret-022",
"provider-023": "fixture-secret-023",
"provider-024": "fixture-secret-024",
"provider-025": "fixture-secret-025",
"provider-026": "fixture-secret-026",
"provider-027": "fixture-secret-027",
"provider-028": "fixture-secret-028",
"provider-029": "fixture-secret-029",
"provider-030": "fixture-secret-030",
"provider-031": "fixture-secret-031",
"provider-032": "fixture-secret-032",
"provider-033": "fixture-secret-033",
"provider-034": "fixture-secret-034",
"provider-035": "fixture-secret-035",
"provider-036": "fixture-secret-036",
"provider-037": "fixture-secret-037",
"provider-038": "fixture-secret-038",
"provider-039": "fixture-secret-039",
"provider-040": "fixture-secret-040",
"provider-041": "fixture-secret-041",
"provider-042": "fixture-secret-042",
"provider-043": "fixture-secret-043",
"provider-044": "fixture-secret-044",
"provider-045": "fixture-secret-045",
"provider-046": "fixture-secret-046",
"provider-047": "fixture-secret-047",
"provider-048": "fixture-secret-048",
"provider-049": "fixture-secret-049",
"provider-050": "fixture-secret-050",
"provider-051": "fixture-secret-051",
"provider-052": "fixture-secret-052",
"provider-053": "fixture-secret-053",
"provider-054": "fixture-secret-054",
"provider-055": "fixture-secret-055",
"provider-056": "fixture-secret-056",
"provider-057": "fixture-secret-057",
"provider-058": "fixture-secret-058",
"provider-059": "fixture-secret-059",
"provider-060": "fixture-secret-060",
"provider-061": "fixture-secret-061",
"provider-062": "fixture-secret-062",
"provider-063": "fixture-secret-063",
"provider-064": "fixture-secret-064",
"provider-065": "fixture-secret-065",
"provider-066": "fixture-secret-066",
"provider-067": "fixture-secret-067",
"provider-068": "fixture-secret-068",
"provider-069": "fixture-secret-069",
"provider-070": "fixture-secret-070",
"provider-071": "fixture-secret-071",
"provider-072": "fixture-secret-072",
"provider-073": "fixture-secret-073",
"provider-074": "fixture-secret-074",
"provider-075": "fixture-secret-075",
"provider-076": "fixture-secret-076",
"provider-077": "fixture-secret-077",
"provider-078": "fixture-secret-078",
"provider-079": "fixture-secret-079",
"provider-080": "fixture-secret-080",
"provider-081": "fixture-secret-081",
"provider-082": "fixture-secret-082",
"provider-083": "fixture-secret-083",
"provider-084": "fixture-secret-084",
"provider-085": "fixture-secret-085",
"provider-086": "fixture-secret-086",
"provider-087": "fixture-secret-087",
"provider-088": "fixture-secret-088",
"provider-089": "fixture-secret-089",
"provider-090": "fixture-secret-090",
"provider-091": "fixture-secret-091",
"provider-092": "fixture-secret-092",
"provider-093": "fixture-secret-093",
"provider-094": "fixture-secret-094",
"provider-095": "fixture-secret-095",
"provider-096": "fixture-secret-096",
"provider-097": "fixture-secret-097",
"provider-098": "fixture-secret-098",
"provider-099": "fixture-secret-099"
},
"tokens": {
"provider-000": "gAAAAAAAD0JARqrDNs-yq83rOzN4Fr-VBvZUACnn102cROns5fJFMGCz2gV-CeZmVVD-Z3SRXFszpHy9GeF6VYqh6YIuT74GuLcs3OXhZbsjnHQLpSgFSEQ=",
"provider-001": "gAAAAAAAD0JAcVlmcEu1YtpyqZwpyVFDekfujUGGX9UvwikB9b5S1fJsFxkREFqvqCkriMyx1fBVVnBksgIkeQs_3wAiItJJ-85u-bI3s3tYNwwbRP3WJc8=",
"provider-002": "gAAAAAAAD0JAfzv-Gsa9LS-TsbJlGu85an8pvoelczxr_4ClBIOfAbb4hnp-BJFOrWrkUFIrdcUYVTNAFMAnCyHkBjk24qf9aHHRAIVhfhY1MOrHlEgmZVc=",
"provider-003": "gAAAAAAAD0JAhTIDM4rkBwD1THGSiV_dDcSypkhB9zpivxbtr8m5Pa2EQ5yZ_aZza28fYPIJctUYYqsp-g39F_p0zmqdMcdMDdEBu-5Rkk98VffMSxVNWX0=",
"provider-004": "gAAAAAAAD0JAL-SAKnLr5dP1fRGIBAheTO2ZmsSMSMzXGrkClU21EAAtr5PKbH00i2r5PjpRpt8419bI_sH16flA0N9Tsc-kXp1mNhCMJa8V5G04bMPG2vY=",
"provider-005": "gAAAAAAAD0JAILcE3HJND9p7zM2Zaar4wZyg1EJL53-qQ_mfmqB4fRGvtGwSBemeAo2jdMwzovmYcA7hWwZ4XYvpkNZgLQYAzDrdwFw4yqBR98iyVO1a72c=",
"provider-006": "gAAAAAAAD0JABOivDjaiCbjTCzY2K6QHqi1n0P3QRiBGzwQt2eutxv5sR1kyYYE4XI3LpI04AplSr6gY7KsXsbz-6pMQrafNFHvlBI8kVCe0MwgsbbBHjyY=",
"provider-007": "gAAAAAAAD0JAgjSooEXGu-V4mZCaxFuNMysTkuLcA54T7HO9lEFQmkJTzCPUjx_THiz5zMciilbPknCaD2HVOx5CSO2DJqXlgTiI2jBIjIDBmmIYHElrgzA=",
"provider-008": "gAAAAAAAD0JAVHspcU_6dp1IpisNd9XULSuwaSsTKKNcw5FtXtUCcLWUVpuFA9qy3DhVIteS83SVyxAOv9aJNc4cgb26z5Q_4-xjCBY2ubH-12pSdTkfsTI=",
"provider-009": "gAAAAAAAD0JAeu1fUbwRb26f07d9C1WPNaIXWuNmMSkulPITmVanLj9IGDQ2fdXf3pkqe171EaUUWiUo9plQC-xC0UOalBQ-HA7kBnPKQuTOqbmBUxOFbws=",
"provider-010": "gAAAAAAAD0JA-BxhhY0FDRAXCg7zAVmm1bHos6KVJt8NrqirUGm_OsWuttNHJw5FfWU_ZCird4uupxQrxpuil1c6FsKbLSfis5wd6cNXGAr0dnoG0bkAzEs=",
"provider-011": "gAAAAAAAD0JArJ3PzUpG7SJev_SpzXIFmpFNnr5uxjvjFmhEEYY2frrtcvheJzqUutllJ4dCecfgUhoFZYZoW6hZzF-VeEiGULtFD6cviCBIJ9rB3Q9n1eI=",
"provider-012": "gAAAAAAAD0JAWDG5yfwVQ5k6ZgFMcZ5BgQT37s9I_uZa24vfGBV7TOPGLDOD3WvejAH2XSm-p70JarQSj6o6f4P-slKBmmlOjBFlor8kN6k5QFKrvl_DrTE=",
"provider-013": "gAAAAAAAD0JAcM0ACOR4TnyaWMSQajkzlh6bpDQkXDiyIjN7JCzQGyPBQ5xnq5o5XVEmdy6s_2KtVNcZ7qn_kxXNvK1tfffgq-08u06MJpzdjDCjt3vBKd8=",
"provider-014": "gAAAAAAAD0JApy7Fg5LFSh_sXlwQ_mIJhropoOnNgfY__LSYrkK65NuNUttVD16i-gn0EZSulZfHLDOjhTLLyFwKOQfjoasuoucJK_R_gegpWu9rgkF2GkE=",
"provider-015": "gAAAAAAAD0JAEql5cEfuPRntNp5p-gIdyfj3Vfiz51KvIl1YISZMYWbQMHXSB1Kud9v5b3WZJGf3EbdCUyGl4-E3xYI85-wSJlCkYaXNJzGaTiZ1OFaqPiE=",
"provider-016": "gAAAAAAAD0JAvE3iFTQdIaFqyM8MCMmR4dxxQMeV-C65_o7m-8BZArFfxBgYTo9zZY_dEj0iVvAM7GORQPEiSr7wJqiOXffLbTsACHRWPMDV-EuKiptxpxc=",
"provider-017": "gAAAAAAAD0JA7RH-NOlL5ARehrlfzea96aDY4rQoE_awhZKZdUUJalJzhvxJJ8B3Z8GpzJlGgM6FSbQfZCIDv1NVh7jikZVfI4Sah1DvS08dx6NrpPyKFp8=",
"provider-018": "gAAAAAAAD0JAQgNdqKNGx1wr-Rz3q9E3nT_Fzhn2Kj-L8XA57p1u6MpHuhUbMTWxojYQKkV5rBW-9ZDPHj4owk1Ac7At0bhoDtI5H-kK-Gj8wmlnfKxUh_I=",
"provider-019": "gAAAAAAAD0JAGLkac-dCevEfv4vHLfGFKav-sjkU1pSGtS1ChrgxKbexQZBsrxW3ThGrpn3zIsjQU-SqpykV3FJF0rRlUDtSy8Du3BMlgNpC2JoyhbUBb1w=",
"provider-020": "gAAAAAAAD0JACZACHyAREZW672Crakxaa0t7R-GFUtqFscUX8nr51oriHlZH3jOu0t6HRAo4KARxT0ZKpnBhlGfM4szpZlhMFwg__WKtMqpxWRrlKYJjh8s=",
"provider-021": "gAAAAAAAD0JAmTxi0REmhen9tXCjNANx2Ik7g2TVVzak-s_hWzwHFlRATxsHg60bpQTNpXFZaC_Z4lSdEO5RMJHqHo3ZT3Gjny4nNqR22fJxsqBJIwC8OFE=",
"provider-022": "gAAAAAAAD0JAPp5h1z5QGy8rwNjNgo7NIGOVC8cnTLG-xL-if6YHLJnQ9DQJ4n6cac3QHfPA-gi1YM6ZalLxbxUy-roueoT59lGQ1Y4OtFDy1jlSXKRn5TY=",
"provider-023": "gAAAAAAAD0JAs0IfMhthrgdTZenjN8gshQGRkiyZG7t2PZfaDv9konfxahHbFFzAW_-U-TKXJQRiXlvSZ_p_3e0upcMJ0Rg8bPPHmo3RwWpe3DD8BwYzY0Q=",
"provider-024": "gAAAAAAAD0JA6DBcyT9ftGfYVoP0aWBdGJENSHu6Jq3z20J004OOcJlHf63zKL4vYR0ZWUfBvpHDz_I2Ol0gLn2woTPv6ul0WB3tzDipo1cH9SUeUS3BUQI=",
"provider-025": "gAAAAAAAD0JA38z2-MzmaHBI--1UAZs8Rxwm7SoDgKPlapvRKrr7zNrI5YVK9v9ZrzJJ3He0oZaKAw3DvYBk9NTJoZuT5Y7eFRX5sHagSlmTLaoysNCFZQg=",
"provider-026": "gAAAAAAAD0JA78csksdCk34WXqhqPIXRd7QpxkoAlrudALMtTuLXOEmIoEIhv2d28fQFok2aq-CAS6CcVCVJhx2hvgpV6PG09tJSWDZTBjqqSp-ANCQsEy8=",
"provider-027": "gAAAAAAAD0JAPLfJkfciuZBZxQFfKsFTVRAGEVRPXGQk_v5WpwvIpmJI0ng_xfeX0T8De9RD02yUuEslI_Gfh_nm-47yVwNIgw-vN3_07eMKKENRNQlKwDk=",
"provider-028": "gAAAAAAAD0JAOQYHZstCBM1A1RPKfHZF67nerogpSQo_sw6pobxsr6oOQ4mNXoDksfEvdLmBzHTQT30UHFbbVHW3r3uMoinjv23-02g9nVM8oOu3mYoZRYs=",
"provider-029": "gAAAAAAAD0JAVGmZPBVRSgBNVGHWbVgUfjP6X2s9CZGenb4c1NHMEC8Oj6Ux8Pwyo0jqyxUMhkqBS8HG7GcWliYzoooxP8b1vN4bXB8GkzfCm2Co-Jfo52A=",
"provider-030": "gAAAAAAAD0JAI6-pQy8G-U3m0S62sMIWPbjndADsysZinZcMlvaH42i-iWCufh-b7MLLh8E1Ki6BQOfbq6K7JyFLL0moarHvuiUNX71rMwFDw6yutshda6Q=",
"provider-031": "gAAAAAAAD0JARThZQX-8Aeoc9M2eNUSwZfQhXmgGmrY6f_nSLbKnM2v8Jivmz-0yLqBTgEQK5firYXim_Uysj09gSC3Bw0_TO7-IkDAQrqNFmVEhwN__Jyk=",
"provider-032": "gAAAAAAAD0JAkEIJgqfvq3ts1jKBYSFDahPn8c7uieUzckrvezbhrfbHEn57BpdH0A6xjFCzBiLlIbU5h-THiVQalKBP8fH80G3GrAcvlx7_9n-KbmE_g1E=",
"provider-033": "gAAAAAAAD0JADU8fN01753ribq0QDDJK9zNgYSgboF-xwpIKJFvwL6GUPr07JCtkz1riGtyU7IYWQHNIx9FyC8G9Jp6eBTycB9Dhm2S4t6X62z8HcG2tG5A=",
"provider-034": "gAAAAAAAD0JA8PhvLE-sVw_bkdcfdr2AX_wVJHWVZDMgln1cOptYmYIdF2ToreQIyNz0ElcSIVZCfyMsKCehz6cuCcetZoLVtBrL5V1ZpWGjqOviXsG5N1Y=",
"provider-035": "gAAAAAAAD0JA29XAVBWD5VjF6Yzmm1PbTESQqkCXB12yyMuT4MnJVweqlMJRX6IJtSfxjwzavko_xLXxHdIv9jYb3sHNXGUpNI5vA-VVDxL928PX695NyXc=",
"provider-036": "gAAAAAAAD0JAUfb73PYyObLEaulCDuLzUNOBDFb1P-GlowbmYxSIlX1Y2A_4FluLopM5XRQl-p94SkwnGZuu_wdieoBpXoz4RZMuKgLlYMfI_B6QAh4zGtI=",
"provider-037": "gAAAAAAAD0JATbfi9uRGgV64yB1u1XLytvUWRGXu8c5bIjr_N6jxUfw8CL4QTtAj092R2FT2lZ2p3x122MU4AyQJFZO-3ARnV1co7w8_gcIoJ3WP19knGIk=",
"provider-038": "gAAAAAAAD0JAKQ3N0O10RN4RId6ZtU2N5fB95sJ4Rt29PQx6VY452GgvttuZFnLJjG44xL4Et_ml27t4Ylu507I86E14yjPI18AnhtagNc0Or-mX3C5J6BY=",
"provider-039": "gAAAAAAAD0JAUmO46r3Stv4c6PLSZxp4vsh1fDvD34e-ukpuyoc6LSO-ijgP20L3Fl2medshUT9KBhgTIXmacUlmZwHD45TPyUsiuh09p6ZrZNuKtlwtcFM=",
"provider-040": "gAAAAAAAD0JAqkGVoQO8cLp-saeL-C5oB_VJh-Oln-HgJgrUlxBCUrsbdYO6oXwUjFWIY-mf9sbide-FlJ9ypbsiuOXWTkkMViIwBntF5P6IduR9fpvhFTQ=",
"provider-041": "gAAAAAAAD0JA0CnlOHJXTwBoeeg0xIxHSi-sn0b4pExJ-z_omfIuvgQYEI2t8FuVK5BVmiqqhpF-X0nOyGJScyNYD30G6s1kXJGkk2wczfAJ56Gr9evt4y0=",
"provider-042": "gAAAAAAAD0JABqbpp-3wu10rJYC_Bcw0PjTS_NSQZ2T2cRbCrP8qX4kOtYF5G8ihmCT3ir427NouPKKiJM4xJLDmVBGohyLwLreGYZJsGrJPTSbvrMevfv8=",
"provider-043": "gAAAAAAAD0JAnZiu7eyx9mQys1lBbkVAIUP2_CyClX39hyEXUuheuvQni0W0vrGqtuwl3g_eQo1UiiO-BoI0TGwJ8T5Kz-vFhbfDnkwEXLTNkVhxXqAgsS0=",
"provider-044": "gAAAAAAAD0JALfymh09LXoeVEQue5fExnPn_dSc5brevZ1IGCGvdboQkRFHrI1bTxBzVjT4mKxZq1C5pmBEdaapiwzyV11vYUvugl-Rw8ofbgi13Bm_OFS8=",
"provider-045": "gAAAAAAAD0JAjmxdzZpREmCgS2125qbKBxTJOlgb6dRvVQqdyyQK7TvlNXD4M9CpJRcbdGq7QOJHvl0lse1lUUSN9wi45O2RzNvEW4qSlbKx65h291kXXe0=",
"provider-046": "gAAAAAAAD0JA6H3YMHtFKu3oKtBOprisdbrA7yFb-bgCUkoQLwtw6NlSjXnIzFczOaQlN7j8Thgt3T_1YnX6zI-3WiJSKwbEWic1XEo3SCRn4TJHI_7X4EE=",
"provider-047": "gAAAAAAAD0JAw6-N0FfdvMGI3-zJ5fYHlr_Ki0CdiYB30XUIAkOzJKD7uPotZRYmjfWGvk-slhyuRR6I2H45I_uJ3L7pnXKwULushf5ssYcYBTCcDcUY13s=",
"provider-048": "gAAAAAAAD0JAZI__BvdD6dY6BOg2YaQdnBzVnGVTPWSFl_67ImM6DKzVnOho-eGaeWpONjJhXFEjJnc1GzhdjgznHJldUG6bz7wzq25sOw_-ZMgJ4xzTYTw=",
"provider-049": "gAAAAAAAD0JAuxRPYRIYy6cCClZj9lsRSadWjicUxThqfll0rL0OaAMYyxw3Y94c7AmJQle3XEY7rtTXrfpXa5OvOD7LSQyKmDayG_Ao7LLFlJxjHXVQnu8=",
"provider-050": "gAAAAAAAD0JAIb2OzqcQxC_Lzb4d7YT0hbvIYzb0GZTx7_6pFosrUk6tMl3FrqP11tNhFpjDoie1WV-eL-XK9hXkS74v3icXNwXa8V7hCMIxVOP65sSWtCw=",
"provider-051": "gAAAAAAAD0JA3kE4__P4FjzyW7fYbl30VkuGWesb2qr-Yx3RE5eZNvyFKo_t1p_smwaq4Wvz12AgCHmQ1gcBZzylVRgK3eG5fAuUYqfMjFwawfTyL3WURE8=",
"provider-052": "gAAAAAAAD0JAIZ5MtE8IOu0hJmzygrrcj-_Ll2AHcBw30rAx9OoVJWh_aSPL2QYjzcGO8xeS_1C_tknFw1WhVF5Uan46IXARdWOY7bnSSVnNKZMX_x_6gDE=",
"provider-053": "gAAAAAAAD0JAwf1PNFe3MaDq7GenXs_Td2zi_cn6FBWcl-tXt_oD2mew2eJ1i30A-IQQMVGuq4nA_wcBuwO8vCJ522XIpLYvhjQE1a1wZkEMtGniRgkJBM8=",
"provider-054": "gAAAAAAAD0JA0f8SsCpmEdpznx_GZo0nuyVEDZaK1GqGMKPqa8T-g4HViS34KovNriBZc-C5Vli7BVplJ547qSGQiATAYiDS69FvE9Mo_fK_RpZsaSVjq3A=",
"provider-055": "gAAAAAAAD0JAj9lMUNoW_vcgRaRHtOCfaaejTQCRMJw_jkObw0r26C7nVgt-Pu7HZm4_9kHuYl4fkm8MbAHIeXfbQp0R9HANDycyx-PSibOk2tGsFFUe9BU=",
"provider-056": "gAAAAAAAD0JAV1dj3mzw5D9bCUv1PZ5Qx2kR0tnhGm37xWxEqS9N-nVj8hu36dkcvJa43Nls7k4ZmQR1mlB00cKECr8M118QG740QMLBHc8HT0EtMuaHUHw=",
"provider-057": "gAAAAAAAD0JA0JIBXd3l6bDibgxCjyHjRdnaoPA2IIaZw1YvtFFc7REAmwloQ2ytnTVN_5lDzWMmtt5tI8dhlD4FqiD9i_ehn-Tom8sMbOU_MkprTtGdQZY=",
"provider-058": "gAAAAAAAD0JAYSoZSA2Gys7bL23LvdCLcvSIeG0kMvY2Az6equFSKGzSHCs2gUF12s7Z-AlFLmRvl5Kw2ZFGqFg0V0OEnKFV_-SCnYzToJ8QPIhHrtRO8t0=",
"provider-059": "gAAAAAAAD0JAUB2Rf_yW5j12i_K693ebcPB24HgRUbhFrMA3ylL-s_MTMqSHATUEDOcA6hLYrbnHkhRqibKUSaSVPt7n0STk90m7Tt5m6KHdmYH1pM09NFo=",
"provider-060": "gAAAAAAAD0JA9DBlKLX-bXYcJhNvHPs1m4_ndWfcuZWU0kMvqhY8hBD0nvt0-UZiNANE33LVrGpSoK5IUbUq6uIAqgiU_Q0lyYWD6Zrv1NL0nozegE2MFYI=",
"provider-061": "gAAAAAAAD0JAwW5G-mJSUkSV4CIBLPtJKQvNKd2Ssjy-srdvExhpPBGAwkIv9yCu_dgM7fBLHRoNuT2cA1FYUu0qKu_lu1Yskxc_g9HG8oZ6WgrUGdmgyTo=",
"provider-062": "gAAAAAAAD0JAbgX9K0OcfZpbb6cK7dJ7Q1tRQLaWCqJNYchBLDgD7N_tC0tOdYHS6Z623oomyUGgooYGKvF6jPhV1LAnD3RtXIpT6OfgDC5hhijKXBGDK5U=",
"provider-063": "gAAAAAAAD0JAKrZAEjUOgmSnTWdpa6wQWOaawO5cK6vwZhJlAaQIOF6SlCURV2-cfNwg5l2vm7ilhP12Qai7S2ljVP3fjsxqN0RcJY4ufUszuwCS-wDcfiM=",
"provider-064": "gAAAAAAAD0JANTxIEMEAxmFDOR4Trm7BZsCRtJLohH0sIrNhhnksy_RwR8lAWddm1Mve-9hy4vvxAuLR1pKRntyK02DD6xSD91Zv-nFN2mhwxww_9GMAUq4=",
"provider-065": "gAAAAAAAD0JAlXfTEsE0QOarTDgc9DG2WNc1kmjx7Jx5EfC1csicneTjsyl6sSb1Et5u5d-OmxcYupRWa3fplAVQyyHSkOtVims1v4rdshKjEsmRHj0j_H4=",
"provider-066": "gAAAAAAAD0JA0MZiSxiJA248fgj7jpUFHde2rQfPIckAKCF515JuBZaVyHEVDdEiog_gyth_QJHERpvJFU58weSlUA-UQRco-mZKaR4WJl5Pt2jVhYQ5SWU=",
"provider-067": "gAAAAAAAD0JAe7XCcR5qvu8WVXZO9hORAQGbhVJEwd3PRSzMq0HZFaIyIfRaIxT7t08GOVmX6P28c3ChTdzq4NPmnbCPLa0xWJ553A_jwszzKw7KnewMapY=",
"provider-068": "gAAAAAAAD0JAUc1xmeaMgsjTVn0o6z0ip-rtMssELSXWGZq45_B_YSlu2jYCocM7R2kKiZ26DMqFh1IEAOviiaTcbI74O9ofpoSD7mg48BAP4rZG1xOHXak=",
"provider-069": "gAAAAAAAD0JABmFuB-1Z_DiB6GFP7eqWCkJpcQmewraRzIdlpoxiiY-TN7m1m6jWFjL2yg9yAAagE2_RoNGbgcW8suvFPlgxbqxlYcF40PcEKxCQFwS_nxE=",
"provider-070": "gAAAAAAAD0JA6Sq2H9uxMrjMJle4M82jhKIxAjskPb_I1eas6Sn60wYQsP9JqiCAFm99a6vpEgWH9gJI9HdLFrmVv7CKy7FIlSl8zO61cfbvxLm78QqG9vM=",
"provider-071": "gAAAAAAAD0JAkpEwTNJN7m82wRRXatd_GjY0Ii2Q4fDSpto5pCgUBffW1hgQr8JwKY4jkO37cLXTVHwh95a76fqbSn7rfHjjNCuGnT99Rdm3md-iqn7D7QE=",
"provider-072": "gAAAAAAAD0JABDTb_d5tRZ2MCVDGKFLFlYdNvgV5WgxUQfubYRoXj_2spZ1IJY0nA9jnOK5_kMqtt8AAk8MowT_O1Gl7wraQozij002mHay_0KpByQZyH1Y=",
"provider-073": "gAAAAAAAD0JA35Hype3h5YrRm1DP04j9d3NcBtSv5DyE8d9-plL17gV6xXa9e3vrIpMNhPLYckCHULbeq_mTGXqpHCITGkyteT2fn8WVLOaFEZ7KxJrZ1ao=",
"provider-074": "gAAAAAAAD0JA53v2Tla3kX3nIaUsaWqdVmKe41U5UCUolouSSv2LqyZZb9l_P5J53ZEOlQC49ZjglwL4fvjmvkg6RyZfAVtITmX0RXIkykzvEvTyIlvVV-c=",
"provider-075": "gAAAAAAAD0JA4bc7magJ5p4ftSOFjt74xAQJlw0_Kr7WyLr25Rj1i8LeBbZTKJ3erKZXZwbLwpN92FObMHHOUbxqq0qKp4Kmvw4wF5C3Bm_LCVD_TKX9DxQ=",
"provider-076": "gAAAAAAAD0JAkVGEJLIdj7cJxMetMzxYt4NrmCLRys28zCeeorUOfcuiqNNImRShzYc9lLtf7w7YoZOhueQ5it2xcCev1DV4RDFJM2tKPjnXroIGYAFbhUw=",
"provider-077": "gAAAAAAAD0JAsQsksP6Z1_YfPix6LBNiILF-y1-fWlku-3wl7hq0DwEDiqr8D-yu3CLfkcVU7E5j9UHbRE2RqT9Ac7Pm2f-Q9Uiliwx1fLK3uFmJuiyrM3c=",
"provider-078": "gAAAAAAAD0JAhhjIEFvZS6kF3IUaQqn1x0aLrOgRcPdcM27tsIXyB7vwZSgqVk7VZIYIr60mupRQD8q1vIo66wFRyPcOO_TdFzUjZXGPX5FvQNpn6s8Vbwo=",
"provider-079": "gAAAAAAAD0JAe94_XDzvvJyJUTqft6Gf7mrpJzmbh9_9X6vdihLktm_0IbuBgm-8qtaMjAlkAp8Wsp3OC-gKSHcQu9ta60N-NUSFCOKIIjvgZbNpB3JNNdk=",
"provider-080": "gAAAAAAAD0JAWYtwtu1cbp3XlEDWjZlBp8LQQ9uO3wFLsdxh60ndemLs3Qmw-2-U2IYSweFqEsAtIYKeNwIofxDq2yAYPCGeg23KtmfWN3Jc_3tpL3GPO1I=",
"provider-081": "gAAAAAAAD0JA1q0GlZjz1qSiM9-S4SNQj_s0ean9rQiKWFi0W2Qu7rKATAmGaZV3J5LQGgfXL3s7K1atVirbQ9mbVRcp2-Nh-yfnTt2nEIE8dsrqaU7FUrM=",
"provider-082": "gAAAAAAAD0JAS4i0A_rFY6SkN1QLpo9wtSNrYWl9u5_9rx7Pv4xHJSPcSs8313mcpNXOponmpVSPKJih3xuOlinBAGPT-WkumY_JLNOMGmu5Hcc6k-UOuVc=",
"provider-083": "gAAAAAAAD0JArrVSvJmJ-9liaD5_Npm_jEJqIXUjTGeWEIkQSjDq1V8TpVSr4b3Guz2N0KGDVZCUpZImgXvsCSY73pCOuMShj5bCi8YBPsf1JgsamYFU_Z4=",
"provider-084": "gAAAAAAAD0JArCrbHbZ_7W51T9WFIsYIYLsNF3ihbs-5DOEKbBLDgbw9aAcfVa1aCwnNESwg8OBABvpKr-5F4Zph_FcGG1LpHd2Ye2pDxx5Ul4Z4Ql6em2w=",
"provider-085": "gAAAAAAAD0JAjlher3s8linCmF_bpTSoQOb-LT3zZAiwd-3T9xMlItGCe6V_R2nt_j8pS2DHIEbLuLriLGwCuVCFDwPPqxODzsRXA9R8goVsH--XMWIDx54=",
"provider-086": "gAAAAAAAD0JAOvWWo66DTyKorw6xougky5D3lk5TiyxXWV-s6Qtsbk1YjCC10Xhk67HExMdcoW9UoeDZLIoqhUBzmNsShiKt9YufI4YYSfPm9aL0gXx2Iug=",
"provider-087": "gAAAAAAAD0JAYyndje5fZAtH9g3nDmE8kp0ZBNTdF6TLgMsErqzInyZRoL3kEVovX26gEgpdBvDJ1-5KvO3dMoXl4ah6scY6O6USnNnKSDDa0CgE9rFG5JU=",
"provider-088": "gAAAAAAAD0JAs40wY8C--WZ9TPF2RzD2Z0riPKgAecv3tBuZ0EEhDrkfST5bz3LJk1_jFUDEsR1BCQYQ_jH_j_AFgjACZnXMAdQcXPKKMei6dpCta_X0dtE=",
"provider-089": "gAAAAAAAD0JA3YClZN2rmHNhTljyP7NOFxdP__GKswG8yDWM-sXwzkcZKxF6FqeZTlISPMtYz5oh_-cpztajYq9ki3kPh4nnGyQZ_MqLq1WRPAt80-YU8Lw=",
"provider-090": "gAAAAAAAD0JAZKyzODbB7olM9rwwSDX6H9a56GtE7hwZuSsKmiS02WUop6w2Lymg-Xm8thwmMnLF-1j5KpGCBo2nH4Xd5x2DuBY35udDRoNobN5BC6-f30Y=",
"provider-091": "gAAAAAAAD0JAD6XKTSgi3huiv0PWsoNF7yH30KDzs0f2cHwW90NRT3nxTjsU6XDVzBrh57vd9kV8ofV-C4Qemf5tpbLirhD1aW9jhdvVfckhTrUQqEitvLo=",
"provider-092": "gAAAAAAAD0JAr8ekPqcoNB4KEjo_gfaoOCcvvpD3ykweN5VxFj_EBckVswLb5KicTwlN7a_a8pJQBbJrsps-mLDDBkxau_EwGSyWxYmVVzaPRbqnCejWaXw=",
"provider-093": "gAAAAAAAD0JAp0KUmZFCNpxy45Y3yKJP2-6ovo8keTVJjKVblQbU_1GSc2YgZcaMMQoHxSz9VMXz-gSuAOhW6fexPyN5QH7JqZIzfrQsIA7XebXo29A3ocI=",
"provider-094": "gAAAAAAAD0JAhBwwkb_yZHrQ2UsRekxEESVaKk1xh46yHJRCO5VMew0SaUy3Sk1HandiQkMXmyHc95leNkRBNcu3vRv7_AIo5hCETlm_wub5aZs3QNuuncw=",
"provider-095": "gAAAAAAAD0JAZASXeJl8WY0k4yT0A4TJQkO_YrGSN-LQEo-_Q6D33v-CXhxPFIUq_AEKlEbnBWgriqRQ5bFC8UtxCvYvJNzV9GIVassojICamFgAPUzJIYg=",
"provider-096": "gAAAAAAAD0JArGgydkoNqqplDShZoTP9VyPRvWDSgLy6lXuAwZPzt-i6E_Xalu5Ww73V4ca6gA6TptseKv35k2p2RZts_LyiWKY4ih_PqjGlrqVbydtazf0=",
"provider-097": "gAAAAAAAD0JASCJxfWQ4irHGf5QUq27tti1CLKUKvxN-wZ5KL5SobwK9s2_tlWbpnnFSwxTWyAr_3PI2PeLfB1WortkvnFtjL1u9V5eaZCXn3khRy14_9oI=",
"provider-098": "gAAAAAAAD0JAZ-K7BGACvTdqfMSRbFcOxaDw-o-TDEN3sIOLrnhePkhFitxThopo2WyRoFUGtOJdWW_U7XMvM_K9-1K-zC8bYphlZOh92rHO0HRyaJ40qMY=",
"provider-099": "gAAAAAAAD0JA6tr96Hbphykg8LKZI4jbIatMSbpVtFZYQtcl4arnshQm2AEu2Z0hblzM8OSvHQWHgl25XItM8XdabZPXRfCD8wVdGI52t-yh4XVCzMQkV7U="
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ const pageTitle = computed(() => {
logs: t('运行日志', 'Operation Logs'),
media: t('音视频转写', 'Media Transcription'),
}
return titles[name] || 'NotesAgent'
return titles[name] || 'OpenNexus'
})
const currentFileName = computed(() => {
@@ -63,7 +63,7 @@ function toggleFromTitlebar(event: MouseEvent) {
</span>
</div>
<div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
<span class="app-name" data-tauri-drag-region>OpenNexus</span>
</div>
<div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
+2 -2
View File
@@ -258,11 +258,11 @@ onMounted(load)
<label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
<div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('兼容', '(legacy)') }}</button></div>
<template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"OpenNexus"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 OpenNexus 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports OpenNexus, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset>
</form>
+23 -4
View File
@@ -2,17 +2,36 @@
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import { isDesktop } from '@/services/platform/desktop'
import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const route = useRoute()
const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission()
const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null)
const audioSource = ref('')
watch(() => selected.value?.attachment_id, async (id, _old, onCleanup) => {
let stale = false
let objectUrl: string | undefined
onCleanup(() => { stale = true; if (objectUrl) URL.revokeObjectURL(objectUrl) })
audioSource.value = ''
if (!id) return
if (!isDesktop()) { audioSource.value = mediaService.audio(id); return }
try {
const response = await apiClient.get<Response>(`/api/media/attachments/${encodeURIComponent(id)}`)
const blob = await response.blob()
if (stale) return
objectUrl = URL.createObjectURL(blob)
audioSource.value = objectUrl
} catch (e) { if (!stale) error.value = (e as Error).message }
})
const file = ref<File | null>(null)
const reference = ref<File | null>(null)
const matchResult = ref('')
@@ -62,7 +81,7 @@ async function action(work: () => Promise<void>) {
async function submit() {
if (!file.value) return
await action(async () => {
if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.'))
if (file.value!.size > maxUploadMiB * 1024 * 1024) throw new Error(t(`文件不能超过 ${maxUploadMiB} MiB。`, `Files cannot exceed ${maxUploadMiB} MiB.`))
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
let terms = {}
if (terminology.value.trim()) {
@@ -119,7 +138,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
</details>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
@@ -141,7 +160,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<audio ref="player" controls :src="audioSource || undefined" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>{{ t('播放速度', 'Playback speed') }}<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
<p v-if="selected.fallback_reason" class="subtle">{{ t('已回退', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { hostInvoke } from '@/services/platform/desktop'
import { t } from '@/i18n'
const locked = ref(true)
const busy = ref(false)
const password = ref('')
const confirmation = ref('')
const message = ref('')
async function refresh() {
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
locked.value = state.locked
}
async function importLegacy() {
busy.value = true; message.value = ''
try {
const count = await hostInvoke<number | null>('credentials_import')
if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`)
} catch (error) { message.value = error instanceof Error ? error.message : 'MIGRATION_FAILED' }
finally { busy.value = false }
}
async function act(action: 'unlock' | 'lock' | 'change_password') {
if (busy.value) return
message.value = ''
if (action === 'change_password' && password.value !== confirmation.value) {
message.value = t('两次口令不一致。', 'The passwords do not match.'); return
}
busy.value = true
const value = password.value
password.value = ''; confirmation.value = ''
try {
await hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value })
await refresh()
message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : ''
} catch (error) { message.value = error instanceof Error ? error.message : 'CREDENTIAL_STORE_FAILED' }
finally { busy.value = false }
}
onMounted(() => refresh().catch(error => { message.value = String(error) }))
</script>
<template>
<section class="panel settings-section credential-vault" aria-labelledby="credential-vault-title">
<h2 id="credential-vault-title">{{ t('设备凭据保险库', 'Device credential vault') }}</h2>
<p>{{ locked ? t('已锁定:使用模型密钥前请解锁。首次解锁将创建本机保险库。', 'Locked: unlock before using provider credentials. The first unlock creates this devices vault.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p>
<p class="subtle">{{ t('口令至少12个字符。遗失口令后需恢复备份或重新配置密钥;笔记仍可使用。', 'Use at least 12 characters. A lost password requires a backup or re-entering credentials; notes remain available.') }}</p>
<form @submit.prevent="act(locked ? 'unlock' : 'change_password')">
<label>{{ locked ? t('解锁口令', 'Vault password') : t('新口令', 'New password') }}
<input v-model="password" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
</label>
<label v-if="!locked">{{ t('确认新口令', 'Confirm new password') }}
<input v-model="confirmation" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
</label>
<div class="inline-actions">
<button class="button-primary" type="submit" :disabled="busy">{{ busy ? t('处理中…', 'Working…') : locked ? t('解锁', 'Unlock') : t('更改口令', 'Change password') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="act('lock')">{{ t('立即锁定', 'Lock now') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="importLegacy">{{ t('迁移旧凭据', 'Import legacy credentials') }}</button>
</div>
</form>
<p v-if="message" role="status">{{ message }}</p>
</section>
</template>
<style scoped>
.credential-vault form { display: grid; gap: 12px; max-width: 480px; }
.credential-vault label { display: grid; gap: 6px; }
.credential-vault input { color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px; }
</style>
@@ -12,6 +12,8 @@ import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import LocalModelSettings from './LocalModelSettings.vue'
import UsageCard from './UsageCard.vue'
import CredentialVaultSettings from './CredentialVaultSettings.vue'
import { isDesktop } from '@/services/platform/desktop'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -85,6 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<section v-if="activeSection === 'general'" class="panel settings-section"><div class="setting-row"><span><strong>{{ t('全局人设', 'Global persona') }}</strong><small>{{ t('统一设置所有 AI 对话和智能体的系统人设与对话示例', 'System persona and examples for all AI chats and agents') }}</small></span><button class="button-secondary" @click="showPersona = true">{{ t('编辑人设与头像', 'Edit persona and avatars') }}</button></div></section>
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
<CredentialVaultSettings v-if="activeSection === 'general' && isDesktop()" />
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label><MarkdownPreferenceSettings /><HeadingStyleSettings /></div>
+1 -1
View File
@@ -63,7 +63,7 @@ async function openFolderPicker() {
<div class="entry-container">
<div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">NotesAgent</h1>
<h1 class="app-title">OpenNexus</h1>
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div>
+1 -1
View File
@@ -94,7 +94,7 @@ router.beforeEach((to) => {
})
export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent'
const baseTitle = 'OpenNexus'
const titles: Record<string, string> = {
logs: t('运行日志', 'Operation logs'),
media: t('音视频转写', 'Media Transcription'),
+15 -5
View File
@@ -2,7 +2,7 @@ import type { ApiError, ErrorResponse } from '@/contracts'
import { hostInvoke, isDesktop } from './platform/desktop'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? (isDesktop() ? 'http://127.0.0.1:8000' : '')
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
interface DesktopCoreResponse {
status: number
@@ -82,12 +82,22 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
if (isDesktop()) {
const parsed = new URL(url)
const parsed = new URL(url, 'http://localhost')
let bodyBase64: string | undefined
if (rest.body instanceof Blob) {
if (rest.body.size > 64 * 1024 * 1024) throw new ApiErrorClass('CORE_REQUEST_TOO_LARGE', '上传文件超过 64 MiB')
const bytes = new Uint8Array(await rest.body.arrayBuffer())
const parts: string[] = []
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
bodyBase64 = btoa(parts.join(''))
}
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
method: rest.method ?? 'GET',
path: `${parsed.pathname}${parsed.search}`,
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
authorization: token ? `Bearer ${token}` : undefined,
bodyBase64,
contentType: new Headers(reqHeaders).get('Content-Type'),
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
})
if (response.status >= 200 && response.status < 300) {
if (response.status === 204) return undefined as T
@@ -136,8 +146,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
export const apiClient = {
postBinary<T>(path: string, body: Blob) {
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
return request<T>(path, { method: 'POST', body, headers })
},
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' })
+5
View File
@@ -1,5 +1,6 @@
import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
import { isDesktop } from './platform/desktop'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob {
@@ -24,6 +25,10 @@ export const mediaService = {
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File, idempotencyKey?: string) {
if (isDesktop()) return apiClient.postBinary<{ attachment_id: string }>(
`/api/media/attachments?filename=${encodeURIComponent(file.name)}`, file,
{'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})},
)
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
})
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
const { invoke, channels } = vi.hoisted(() => ({ invoke: vi.fn(), channels: [] as { onmessage: (message: unknown) => void }[] }))
vi.mock('./desktop', () => ({ hostInvoke: invoke }))
vi.mock('@tauri-apps/api/core', () => ({ Channel: class { onmessage = (_message: unknown) => {}; constructor() { channels.push(this) } } }))
import { coreStream } from './coreStream'
beforeEach(() => { channels.length = 0; invoke.mockReset(); invoke.mockResolvedValue(undefined) })
it('preserves UTF-8 byte fragments and cursor without exposing authorization', async () => {
const pending = coreStream('/api/events', { method: 'GET', headers: { 'Last-Event-ID': '42', Authorization: 'must-not-forward' } })
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'chunk', data: '5A==' })
channels[0]!.onmessage({ kind: 'chunk', data: 'uK0=' })
channels[0]!.onmessage({ kind: 'done' })
expect(await response.text()).toBe('中')
expect(invoke.mock.calls[0]![1]).toMatchObject({ lastEventId: '42' })
expect(JSON.stringify(invoke.mock.calls)).not.toContain('must-not-forward')
})
it('cancels a native request even when abort arrives before start acknowledgement', async () => {
let acknowledge!: () => void
invoke.mockImplementationOnce(() => new Promise<void>(resolve => { acknowledge = resolve }))
const abort = new AbortController()
const pending = coreStream('/api/events', { signal: abort.signal })
abort.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
acknowledge()
await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith('core_stream_cancel', expect.anything()))
})
it('propagates native failure after headers to the response reader', async () => {
const pending = coreStream('/api/events', {})
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'error', code: 'CORE_RESPONSE_ERROR' })
await expect(response.text()).rejects.toThrow('CORE_RESPONSE_ERROR')
})
@@ -0,0 +1,52 @@
import { Channel } from '@tauri-apps/api/core'
import { hostInvoke } from './desktop'
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
| { kind: 'done' } | { kind: 'error'; code: string }
/** Native session credentials stay in Rust; this channel carries response bytes only. */
export function coreStream(path: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
let ended = false
let started = false
let controller: ReadableStreamDefaultController<Uint8Array>
const cancelHost = () => hostInvoke('core_stream_cancel', { requestId }).catch(() => {})
const cleanup = () => init.signal?.removeEventListener('abort', abort)
const fail = (error: Error) => {
if (ended) return
ended = true
cleanup()
controller.error(error)
reject(error)
if (started) void cancelHost()
}
const abort = () => fail(new DOMException('Request aborted', 'AbortError'))
const stream = new ReadableStream<Uint8Array>({
start(value) { controller = value },
cancel() { ended = true; cleanup(); if (started) void cancelHost() },
})
const channel = new Channel<Message>()
channel.onmessage = message => {
if (ended) return
if (message.kind === 'headers') resolve(new Response(stream, { status: message.status, headers: { 'Content-Type': 'text/event-stream' } }))
if (message.kind === 'chunk') {
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
controller.enqueue(bytes)
// Bound queued data if a consumer stops reading without cancelling.
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
}
if (message.kind === 'error') fail(new Error(message.code))
if (message.kind === 'done') { ended = true; cleanup(); controller.close() }
}
init.signal?.addEventListener('abort', abort, { once: true })
if (init.signal?.aborted) { abort(); return }
let body: unknown
try { body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined }
catch { fail(new Error('CORE_BODY_INVALID')); return }
void hostInvoke('core_stream', {
requestId, path, method: init.method ?? 'GET', body,
lastEventId: new Headers(init.headers).get('Last-Event-ID') ?? undefined, channel,
}).then(() => { started = true; if (ended) void cancelHost() }).catch(fail)
})
}
+5 -2
View File
@@ -1,4 +1,6 @@
import { resolveApiUrl } from './apiClient'
import { isDesktop } from './platform/desktop'
import { coreStream } from './platform/coreStream'
export type SseEventHandler = (
event: string,
@@ -47,12 +49,13 @@ export class SseClient {
headers['Last-Event-ID'] = lastEventId
}
const resp = await fetch(resolveApiUrl(url), {
const init: RequestInit = {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal,
})
}
const resp = isDesktop() ? await coreStream(url, init) : await fetch(resolveApiUrl(url), init)
if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`)
+2 -2
View File
@@ -21,7 +21,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
author: 'OpenNexus 团队',
description: t.description,
is_dark: t.is_dark,
builtin: true,
@@ -30,7 +30,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
author: 'OpenNexus 团队',
description: t.description,
min_app_version: '0.1.0',
is_dark: t.is_dark,
+46
View File
@@ -0,0 +1,46 @@
"""Build an onedir Core from the frozen packaging environment, then inventory it.
Run: uv run --directory backend --group packaging python ../scripts/build-core.py
Outputs remain in this worktree's ignored .build directory.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def main():
output = ROOT / ".build" / "sidecar"
output.mkdir(parents=True, exist_ok=True)
subprocess.run([
sys.executable, "-m", "PyInstaller", "--noconfirm", "--onedir",
"--name", "opennexus-core", "--distpath", str(output / "dist"),
"--workpath", str(output / "work"), "--specpath", str(output),
"--collect-submodules", "app", "--collect-all", "sqlite_vec",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "text-tools") + ":extensions/plugins/text-tools",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "chat-policy") + ":extensions/plugins/chat-policy",
"--add-data", str(ROOT / "backend" / "extensions" / "skills" / "knowledge-assistant") + ":extensions/skills/knowledge-assistant",
"--add-data", str(ROOT / "backend" / "extensions" / "skills" / "chat-operator") + ":extensions/skills/chat-operator",
"--copy-metadata", "cryptography", "--copy-metadata", "uvicorn",
str(ROOT / "backend" / "sidecar_entry.py"),
], cwd=ROOT / "backend", check=True)
bundle = output / "dist" / "opennexus-core"
files = {}
for path in sorted(bundle.rglob("*")):
if path.is_symlink():
raise RuntimeError("Core bundle contains a link")
if path.is_file():
with path.open("rb") as stream:
files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest()
manifest = {"protocol": 1, "product": "OpenNexus", "files": files,
"lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest()}
(output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":")), encoding="utf-8")
print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}")
if __name__ == "__main__":
main()
+15 -3
View File
@@ -10,9 +10,18 @@ from .database import Database
from .storage import S3Objects
def application():
url = os.environ["SYNC_DATABASE_URL"]
if not url.startswith("postgresql+psycopg://"):
raise RuntimeError("生产入口只支持 PostgreSQL")
return create_app(Database(url), S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"]),
Path(os.environ["SYNC_STAGING_DIR"]))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["serve", "migrate", "create-user"])
parser.add_argument("command", choices=["serve", "migrate", "create-user", "cleanup-uploads"])
parser.add_argument("--workers", type=int, choices=[1, 2], default=2)
parser.add_argument("--username")
args = parser.parse_args()
url = os.environ["SYNC_DATABASE_URL"]
@@ -24,8 +33,11 @@ def main():
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
elif args.command == "serve":
import uvicorn
app = create_app(db, S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"]), Path(os.environ["SYNC_STAGING_DIR"]))
uvicorn.run(app, host="0.0.0.0", port=8080, access_log=False)
uvicorn.run("sync_server.__main__:application", factory=True, workers=args.workers,
host="0.0.0.0", port=8080, access_log=False)
elif args.command == "cleanup-uploads":
from .maintenance import cleanup_expired_uploads
print(cleanup_expired_uploads(db, Path(os.environ["SYNC_STAGING_DIR"])))
if __name__ == "__main__":
+118 -14
View File
@@ -4,11 +4,17 @@ import hashlib
import json
import secrets
import time
import os
import tempfile
import asyncio
from contextlib import asynccontextmanager
import logging
from pathlib import Path
from fastapi import FastAPI, Header, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from fastapi.responses import JSONResponse, FileResponse
from starlette.background import BackgroundTask
from .database import Database, password_hash, row, rows, run
from .models import Commit, Login, Refresh, Upload, VaultCreate
@@ -26,12 +32,36 @@ def digest(value: str) -> str:
def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=time.time):
db.migrate()
staging.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="NotesAgent Sync", version="1.0.0")
@asynccontextmanager
async def lifespan(_app):
from .maintenance import cleanup_expired_uploads
stopping = asyncio.Event()
async def maintain():
while not stopping.is_set():
try:
await asyncio.to_thread(cleanup_expired_uploads, db, staging, now=int(clock()))
except Exception:
logging.getLogger(__name__).error("UPLOAD_MAINTENANCE_FAILED")
try:
await asyncio.wait_for(stopping.wait(), timeout=60)
except TimeoutError:
pass
worker = asyncio.create_task(maintain())
try:
yield
finally:
stopping.set()
await worker
app = FastAPI(title="OpenNexus Sync", version="1.0.0", lifespan=lifespan)
app.state.database = db
@app.exception_handler(SyncError)
async def error(_request, exc):
return JSONResponse({"error": {"code": exc.code, "details": exc.details}}, status_code=exc.status)
headers = {"Retry-After": "60"} if exc.status == 429 else {}
return JSONResponse({"error": {"code": exc.code, "details": exc.details}}, status_code=exc.status, headers=headers)
@app.exception_handler(RequestValidationError)
async def invalid(_request, _exc):
@@ -66,12 +96,41 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
def health():
return {"status": "ok"}
@app.get("/ready")
def ready():
def readiness_probe():
with db.transaction() as conn:
if row(conn, "SELECT version FROM schema_version")["version"] != 1:
raise SyncError(503, "SCHEMA_INCOMPATIBLE")
return {"status": "ready", "schema": 1}
key = "health-probe/" + secrets.token_hex(16)
try:
with tempfile.TemporaryFile(dir=staging) as local:
local.write(b"opennexus-ready")
local.flush()
os.fsync(local.fileno())
local.seek(0)
if local.read() != b"opennexus-ready":
raise OSError("STAGING_INTEGRITY")
objects.put(key, b"opennexus-ready")
if objects.get(key) != b"opennexus-ready":
raise OSError("STORAGE_INTEGRITY")
finally:
objects.delete(key)
ready_lock = asyncio.Lock()
ready_cache = {"until": 0.0, "ok": False}
@app.get("/ready")
async def ready():
async with ready_lock:
if time.monotonic() >= ready_cache["until"]:
try:
await asyncio.wait_for(asyncio.to_thread(readiness_probe), timeout=3)
ready_cache["ok"] = True
except Exception:
ready_cache["ok"] = False
ready_cache["until"] = time.monotonic() + 5
if not ready_cache["ok"]:
raise SyncError(503, "DEPENDENCY_UNAVAILABLE")
return {"status": "ready", "schema": 1}
@app.get("/sync/v1/handshake")
def handshake(protocol: int = 1):
@@ -171,10 +230,26 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
raise SyncError(404, "UPLOAD_EXPIRED")
return upload
def reconcile_staging(upload):
path = staging / upload["id"]
try:
length = path.stat().st_size
if length < upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True})
if length > upload["offset_bytes"]:
with path.open("r+b") as stream:
stream.truncate(upload["offset_bytes"])
stream.flush()
os.fsync(stream.fileno())
except OSError:
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True}) from None
return path
@app.get("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
def upload_status(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]}
@app.put("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
@@ -187,6 +262,7 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
raise SyncError(413, "CHUNK_TOO_LARGE")
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
if offset != upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]})
if offset + len(data) > upload["size"]:
@@ -212,15 +288,24 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
@app.post("/sync/v1/vaults/{vault_id}/uploads/{upload_id}/complete")
def complete_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn:
session, _ = vault(conn, vault_id, authorization, lock=True)
receipt = row(conn, "SELECT hash FROM upload_receipts WHERE id=:id AND vault_id=:v AND device_id=:d",
id=upload_id, v=vault_id, d=session["device_id"])
if receipt:
return {"complete": True, "content_hash": receipt["hash"]}
upload = authorized_upload(conn, vault_id, upload_id, authorization)
data = (staging / upload_id).read_bytes()
if len(data) != upload["size"] or len(data) != upload["offset_bytes"] or hashlib.sha256(data).hexdigest() != upload["hash"]:
path = reconcile_staging(upload)
with path.open("rb") as stream:
content_hash = hashlib.file_digest(stream, "sha256").hexdigest()
if upload["size"] != upload["offset_bytes"] or content_hash != upload["hash"]:
raise SyncError(422, "OBJECT_INTEGRITY")
exists = row(conn, "SELECT hash FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=upload["hash"])
if not exists:
objects.put(vault_id + "/" + upload["hash"], data)
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=len(data), now=int(clock()))
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=len(data), v=vault_id)
objects.put_file(vault_id + "/" + upload["hash"], path, upload["hash"])
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=upload["size"], now=int(clock()))
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=upload["size"], v=vault_id)
run(conn, "INSERT INTO upload_receipts VALUES (:id,:v,:d,:h,:now)", id=upload_id,
v=vault_id, d=session["device_id"], h=upload["hash"], now=int(clock()))
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload_id)
(staging / upload_id).unlink(missing_ok=True)
return {"complete": True, "content_hash": upload["hash"]}
@@ -288,9 +373,28 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
obj = row(conn, "SELECT * FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=content_hash)
if not obj:
raise SyncError(404, "OBJECT_NOT_FOUND")
data = objects.get(vault_id + "/" + content_hash)
if len(data) != obj["size"] or hashlib.sha256(data).hexdigest() != content_hash:
expected_size = obj["size"]
# Verify before returning any bytes, without holding a database transaction
# or buffering an entire attachment in RAM.
temporary = tempfile.NamedTemporaryFile(prefix="download-", dir=staging, delete=False)
path = Path(temporary.name)
try:
size, checksum = 0, hashlib.sha256()
with temporary, objects.open(vault_id + "/" + content_hash) as source:
while chunk := source.read(1048576):
size += len(chunk)
if size > expected_size:
raise SyncError(503, "STORAGE_INTEGRITY")
checksum.update(chunk)
temporary.write(chunk)
if size != expected_size or checksum.hexdigest() != content_hash:
raise SyncError(503, "STORAGE_INTEGRITY")
return Response(data, media_type="application/octet-stream", headers={"ETag": '"' + content_hash + '"', "Cache-Control": "private, no-store"})
return FileResponse(path, media_type="application/octet-stream",
headers={"ETag": '"' + content_hash + '"', "Cache-Control": "private, no-store"},
background=BackgroundTask(path.unlink, missing_ok=True))
except BaseException:
temporary.close()
path.unlink(missing_ok=True)
raise
return app
+7 -1
View File
@@ -16,6 +16,7 @@ SCHEMA = [
"CREATE TABLE IF NOT EXISTS revisions (vault_id TEXT NOT NULL, sequence BIGINT NOT NULL, file_id TEXT NOT NULL, base_revision BIGINT NOT NULL, path TEXT NOT NULL, path_key TEXT NOT NULL, operation TEXT NOT NULL, hash TEXT, size BIGINT NOT NULL, device_id TEXT NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(vault_id, sequence), UNIQUE(vault_id, operation_id))",
"CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))",
"CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)",
]
@@ -27,11 +28,16 @@ def password_hash(password: str, salt: str | None = None) -> str:
class Database:
def __init__(self, url: str):
self.engine = create_engine(url)
options = {"connect_args": {"connect_timeout": 2, "options": "-c statement_timeout=30000 -c lock_timeout=5000"},
"pool_timeout": 2, "pool_pre_ping": True} if url.startswith("postgresql") else {}
self.engine = create_engine(url, **options)
self.sqlite = self.engine.dialect.name == "sqlite"
def migrate(self):
with self.transaction() as conn:
if not self.sqlite:
# Serialize factory startup migrations across the supported workers.
conn.execute(text("SELECT pg_advisory_xact_lock(1330534488)"))
conn.execute(text(SCHEMA[0]))
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
if version not in {None, 1}:
+26
View File
@@ -0,0 +1,26 @@
"""Delete expired upload staging only; referenced historical objects are never GC'd."""
import re
import time
from pathlib import Path
from .database import row, rows, run
def cleanup_expired_uploads(db, staging: Path, *, now=None, limit=500):
now = int(time.time() if now is None else now)
with db.transaction() as conn:
expired = rows(conn, "SELECT id,vault_id FROM uploads WHERE expires<=:now ORDER BY expires LIMIT :limit", now=now, limit=limit)
removed = 0
for candidate in expired:
# Same lock order as PUT/complete. Recheck expiry after acquiring the lock.
with db.transaction() as conn:
suffix = " FOR UPDATE" if not db.sqlite else ""
row(conn, "SELECT id FROM vaults WHERE id=:v" + suffix, v=candidate["vault_id"])
upload = row(conn, "SELECT id FROM uploads WHERE id=:id AND expires<=:now", id=candidate["id"], now=now)
if upload:
if not re.fullmatch(r"[0-9a-f]{32}", upload["id"]):
raise RuntimeError("UPLOAD_ID_INVALID")
# File first: interruption leaves an expired row that can be retried.
(staging / upload["id"]).unlink(missing_ok=True)
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload["id"])
removed += 1
return {"expired_uploads_removed": removed}
+43 -1
View File
@@ -4,6 +4,8 @@ from pathlib import Path
import hashlib
import os
import tempfile
import shutil
from contextlib import contextmanager
class DiskObjects:
@@ -27,6 +29,30 @@ class DiskObjects:
def get(self, key: str) -> bytes:
return (self.root / key).read_bytes()
def put_file(self, key: str, path: Path, content_hash: str):
target = self.root / key
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream:
temporary = Path(stream.name)
try:
with path.open("rb") as source:
shutil.copyfileobj(source, stream, 1024 * 1024)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
stream.close()
temporary.unlink(missing_ok=True)
raise
try:
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
@contextmanager
def open(self, key: str):
with (self.root / key).open("rb") as stream:
yield stream
def delete(self, key: str):
(self.root / key).unlink(missing_ok=True)
@@ -34,7 +60,10 @@ class DiskObjects:
class S3Objects:
def __init__(self, endpoint: str, bucket: str):
import boto3
self.client = boto3.client("s3", endpoint_url=endpoint)
from botocore.config import Config
self.client = boto3.client("s3", endpoint_url=endpoint,
config=Config(connect_timeout=2, read_timeout=2,
retries={"max_attempts": 0}))
self.bucket = bucket
def put(self, key: str, data: bytes):
@@ -46,5 +75,18 @@ class S3Objects:
with response["Body"] as stream:
return stream.read()
def put_file(self, key: str, path: Path, content_hash: str):
from boto3.s3.transfer import TransferConfig
with path.open("rb") as stream:
self.client.upload_fileobj(stream, self.bucket, key,
ExtraArgs={"Metadata": {"sha256": content_hash}},
Config=TransferConfig(use_threads=False, max_concurrency=1))
@contextmanager
def open(self, key: str):
response = self.client.get_object(Bucket=self.bucket, Key=key)
with response["Body"] as stream:
yield stream
def delete(self, key: str):
self.client.delete_object(Bucket=self.bucket, Key=key)
@@ -0,0 +1,99 @@
"""Fault vectors for the production IO paths; database still uses an isolated fixture."""
import hashlib
import pytest
from fastapi.testclient import TestClient
from sync_server.app import create_app
from sync_server.database import Database, row
from sync_server.maintenance import cleanup_expired_uploads
from sync_server.storage import DiskObjects
from test_protocol import setup, upload
@pytest.fixture
def env(tmp_path):
db = Database("sqlite:///" + str(tmp_path / "sync.db"))
store = DiskObjects(tmp_path / "objects")
staging = tmp_path / "staging"
now = [1000]
app = create_app(db, store, staging, clock=lambda: now[0])
db.add_user("alice", "controlled-fixture-password")
with TestClient(app) as client:
yield client, db, store, staging, now
db.engine.dispose()
def test_offset_reconciliation_never_acknowledges_missing_disk_bytes(env):
client, _, _, staging, _ = env
auth, _, base = setup(client)
info = client.post(base + "/uploads", headers=auth,
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3}).json()
path = base + "/uploads/" + info["upload_id"]
assert client.put(path + "?offset=0", headers=auth, content=b"a").status_code == 200
local = staging / info["upload_id"]
local.write_bytes(b"abc")
assert client.get(path, headers=auth).json()["offset"] == 1
assert local.read_bytes() == b"a"
local.write_bytes(b"")
assert client.get(path, headers=auth).json()["error"]["code"] == "UPLOAD_DAMAGED"
assert client.put(path + "?offset=1", headers=auth, content=b"bc").status_code == 409
def test_complete_retry_has_durable_receipt_and_charges_once(env):
client, db, _, staging, now = env
auth, _, base = setup(client)
info = client.post(base + "/uploads", headers=auth,
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3}).json()
path = base + "/uploads/" + info["upload_id"]
assert client.put(path + "?offset=0", headers=auth, content=b"abc").status_code == 200
first = client.post(path + "/complete", headers=auth)
assert first.status_code == 200
assert not (staging / info["upload_id"]).exists()
for _ in range(100):
retry = client.post(path + "/complete", headers=auth)
assert retry.status_code == 200
assert retry.json() == first.json()
assert client.post(path + "/complete").status_code == 401
with db.transaction() as conn:
assert row(conn, "SELECT used FROM vaults WHERE id=:id", id=base.rsplit("/", 1)[1])["used"] == 3
assert row(conn, "SELECT COUNT(*) AS n FROM upload_receipts")["n"] == 1
def test_download_is_verified_before_response_and_temp_files_are_removed(env):
client, _, store, staging, _ = env
auth, _, base = setup(client)
sha = upload(client, base, auth)
# This path must use streaming open(), never the full-object get().
store.get = lambda key: pytest.fail("full-object read")
response = client.get(base + "/objects/" + sha, headers=auth)
assert response.content == b"controlled note"
assert list(staging.glob("download-*")) == []
(store.root / base.rsplit("/", 1)[1] / sha).write_bytes(b"corruption")
response = client.get(base + "/objects/" + sha, headers=auth)
assert response.status_code == 503
assert b"corruption" not in response.content
assert list(staging.glob("download-*")) == []
def test_cleanup_only_expired_staging_preserves_committed_objects(env):
client, db, store, staging, now = env
auth, _, base = setup(client)
sha = upload(client, base, auth)
info = client.post(base + "/uploads", headers=auth,
json={"content_hash": hashlib.sha256(b"pending").hexdigest(), "size": 7}).json()
assert cleanup_expired_uploads(db, staging, now=1001)["expired_uploads_removed"] == 0
now[0] += 3601
assert cleanup_expired_uploads(db, staging, now=now[0])["expired_uploads_removed"] == 1
assert not (staging / info["upload_id"]).exists()
assert cleanup_expired_uploads(db, staging, now=now[0])["expired_uploads_removed"] == 0
assert store.get(base.rsplit("/",1)[1] + "/" + sha) == b"controlled note"
with db.transaction() as conn:
assert row(conn,"SELECT used FROM vaults")["used"] == len(b"controlled note")
def test_readiness_fails_closed_when_object_storage_unavailable(env):
client, _, store, _, _ = env
def unavailable(*args):
raise OSError("simulated outage")
store.put = unavailable
assert client.get("/ready").status_code == 503
assert client.get("/health").status_code == 200