feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
59 changed files with 4242 additions and 102 deletions
Showing only changes of commit 4c79e940d2 - Show all commits
+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 基线。 > 第二阶段收尾(开发分支,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)。 > 第三阶段分支状态(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 尚未接入。 截至 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 ```text
+1 -1
View File
@@ -32,7 +32,7 @@ class Settings:
def get_settings() -> Settings: def get_settings() -> Settings:
data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data"))) data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data")))
return Settings( 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"), version=os.getenv("APP_VERSION", "0.1.0"),
environment=os.getenv("APP_ENVIRONMENT", "development"), environment=os.getenv("APP_ENVIRONMENT", "development"),
host=os.getenv("APP_HOST", "127.0.0.1"), host=os.getenv("APP_HOST", "127.0.0.1"),
+4 -3
View File
@@ -13,6 +13,7 @@ from app.providers.credentials import (
ChainedCredentialResolver, ChainedCredentialResolver,
EncryptedCredentialStore, EncryptedCredentialStore,
EnvironmentCredentialResolver, EnvironmentCredentialResolver,
HostCredentialStore,
) )
@@ -21,7 +22,7 @@ class ApplicationContainer:
providers: ProviderRegistry providers: ProviderRegistry
provider_factory: ProviderFactory provider_factory: ProviderFactory
model_routing: ModelRoutingService model_routing: ModelRoutingService
credentials: EncryptedCredentialStore credentials: EncryptedCredentialStore | HostCredentialStore
tools: ToolRegistry tools: ToolRegistry
permissions: PermissionManager permissions: PermissionManager
skills: SkillRuntime skills: SkillRuntime
@@ -32,9 +33,9 @@ class ApplicationContainer:
def build_container() -> ApplicationContainer: def build_container() -> ApplicationContainer:
settings = get_settings() settings = get_settings()
credentials = EncryptedCredentialStore() credentials = HostCredentialStore() if settings.environment == "desktop" else EncryptedCredentialStore()
provider_factory = ProviderFactory( provider_factory = ProviderFactory(
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) credentials if settings.environment == "desktop" else ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
) )
providers = ProviderRegistry(provider_factory) providers = ProviderRegistry(provider_factory)
providers.register( 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 os
import re import re
import threading import threading
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import ClassVar, Protocol from typing import ClassVar, Protocol
@@ -24,6 +25,37 @@ class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ... 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: def validate_provider_credential_id(credential_id: str | None) -> None:
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。""" """阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
@@ -62,6 +94,33 @@ class EncryptedCredentialStore:
def __init__(self) -> None: def __init__(self) -> None:
self._lock = threading.RLock() 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 @staticmethod
def _validate_id(credential_id: str) -> None: def _validate_id(credential_id: str) -> None:
if not _CREDENTIAL_ID.fullmatch(credential_id): if not _CREDENTIAL_ID.fullmatch(credential_id):
@@ -155,7 +214,7 @@ class EncryptedCredentialStore:
self._validate_id(credential_id) self._validate_id(credential_id)
if not secret: if not secret:
raise CredentialStoreError("Credential secret cannot be empty.") raise CredentialStoreError("Credential secret cannot be empty.")
with self._lock: with self._operation_lock():
tokens = self._read_tokens() tokens = self._read_tokens()
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii") token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
tokens[credential_id] = token tokens[credential_id] = token
@@ -165,7 +224,7 @@ class EncryptedCredentialStore:
if not credential_id: if not credential_id:
return None return None
self._validate_id(credential_id) self._validate_id(credential_id)
with self._lock: with self._operation_lock():
token = self._read_tokens().get(credential_id) token = self._read_tokens().get(credential_id)
if token is None: if token is None:
return None return None
@@ -176,12 +235,12 @@ class EncryptedCredentialStore:
def has(self, credential_id: str) -> bool: def has(self, credential_id: str) -> bool:
self._validate_id(credential_id) self._validate_id(credential_id)
with self._lock: with self._operation_lock():
return credential_id in self._read_tokens() return credential_id in self._read_tokens()
def delete(self, credential_id: str) -> bool: def delete(self, credential_id: str) -> bool:
self._validate_id(credential_id) self._validate_id(credential_id)
with self._lock: with self._operation_lock():
tokens = self._read_tokens() tokens = self._read_tokens()
removed = tokens.pop(credential_id, None) is not None removed = tokens.pop(credential_id, None) is not None
if removed: if removed:
@@ -193,7 +252,7 @@ class EncryptedCredentialStore:
for credential_id in credential_ids: for credential_id in credential_ids:
self._validate_id(credential_id) self._validate_id(credential_id)
with self._lock: with self._operation_lock():
tokens = self._read_tokens() tokens = self._read_tokens()
removed = { removed = {
credential_id credential_id
@@ -212,7 +271,7 @@ class EncryptedCredentialStore:
for old_id, new_id in replacements.items(): for old_id, new_id in replacements.items():
self._validate_id(old_id) self._validate_id(old_id)
self._validate_id(new_id) self._validate_id(new_id)
with self._lock: with self._operation_lock():
tokens = self._read_tokens() tokens = self._read_tokens()
changed = False changed = False
for old_id, new_id in replacements.items(): for old_id, new_id in replacements.items():
+2
View File
@@ -11,6 +11,8 @@ def web_vault_ownership():
"""与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。""" """与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。"""
from app.config import get_settings from app.config import get_settings
from app.errors import ApiError from app.errors import ApiError
if get_settings().environment == 'desktop':
raise ApiError(409, 'WORKSPACE_OWNER_DESKTOP', '桌面笔记写入必须通过 Rust Host')
root = get_settings().vault_path root = get_settings().vault_path
managed = root / '.ainote' managed = root / '.ainote'
if managed.is_symlink() or (hasattr(managed, 'is_junction') and managed.is_junction()): 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 = [ dev = [
"pytest>=8.4,<9.0", "pytest>=8.4,<9.0",
] ]
packaging = [
"pyinstaller>=6.16,<7",
]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] 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: def test_service_status() -> None:
response = asyncio.run(service_status()) response = asyncio.run(service_status())
assert response.name == "Notes Agent AI Core" assert response.name == "OpenNexus AI Core"
assert response.status == "ok" 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 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: def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None:
store = EncryptedCredentialStore() store = EncryptedCredentialStore()
store.put("plugin.first", "first") 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'", "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]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.5" 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" }, { 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]] [[package]]
name = "matplotlib" name = "matplotlib"
version = "3.11.1" version = "3.11.1"
@@ -1110,6 +1131,9 @@ dependencies = [
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
] ]
packaging = [
{ name = "pyinstaller" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
@@ -1131,6 +1155,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.4,<9.0" }] dev = [{ name = "pytest", specifier = ">=8.4,<9.0" }]
packaging = [{ name = "pyinstaller", specifier = ">=6.16,<7" }]
[[package]] [[package]]
name = "numpy" 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" }, { 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]] [[package]]
name = "pillow" name = "pillow"
version = "12.3.0" 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" }, { 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]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.3.2" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" 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" }, { 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]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
+2 -2
View File
@@ -1,10 +1,10 @@
# NotesAgent 文档索引 # OpenNexus 文档索引
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。 > 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。 本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录的可测试原型;它仍缺少 SidecarStronghold生产插件隔离、完整同步客户端和发布门禁,不能作为第三阶段发布候选。状态与证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。 当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录原型,并开始接入受认证的 SidecarStronghold生产插件隔离、完整同步客户端和发布门禁仍未完成,不能作为发布候选。最新状态见[OpenNexus 生产化实施进度](development/OpenNexus生产化实施进度-2026-09-08.md),历史证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。
仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。 仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。
@@ -1,6 +1,6 @@
# 第三阶段五项生产化能力:工程规划与验收目标 # 第三阶段五项生产化能力:工程规划与验收目标
日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`。状态:**仅规划,五项均未通过生产验收;本文不交付功能、安装包或运行证据。** 日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`初始规划提交:`2c4287e`。用户随后授权全量实施,并确定正式名称为 **OpenNexus**状态:**实施中,五项均未通过完整生产验收**;实际证据见[实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。
## 1. 范围、文档优先级与交付口径 ## 1. 范围、文档优先级与交付口径
@@ -8,7 +8,7 @@
“通过”要求实现、自动化报告、平台实测和恢复演练同时满足。旧记录中的测试数量不继承为本计划证据。五项完成不代表多窗口、全部社区内容能力、OCR/音频质量等第三阶段其他退出项完成。 “通过”要求实现、自动化报告、平台实测和恢复演练同时满足。旧记录中的测试数量不继承为本计划证据。五项完成不代表多窗口、全部社区内容能力、OCR/音频质量等第三阶段其他退出项完成。
本次仅允许文档提交。实施期间测试只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。 初始规划轮仅允许文档提交;后续实施已获用户授权。实施期间测试只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。
## 2. 现状审计与差距 ## 2. 现状审计与差距
@@ -256,7 +256,7 @@ runner输出JUnit、逐ID JSON、耗时/峰值内存、进程树/拒绝访问计
## 14. 非目标与仍需产品确认的事项 ## 14. 非目标与仍需产品确认的事项
明确非目标:本次任务功能实现;移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。 明确非目标:移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。
以下在P0由产品/安全/运维共同确认,当前采用表中工程默认值推进设计,不把未答复当批准: 以下在P0由产品/安全/运维共同确认,当前采用表中工程默认值推进设计,不把未答复当批准:
+4 -4
View File
@@ -8,8 +8,8 @@
| 命令 | 参数 / 返回 | | 命令 | 参数 / 返回 |
| --- | --- | | --- | --- |
| `host_capabilities` | protocol=1workspace/core=truesync/credentials/extensions=false | | `host_capabilities` | protocol=1workspace/credentials=truecore 随进程状态返回;sync/extensions=false |
| `core_request` | 代理固定的 `http://127.0.0.1:8000/health``/api/*` JSON 请求;路径、方法和超时由 Host 限制 | | `core_request` | 代理认证 Sidecar 的 `/health``/api/*`;Host 注入会话和代际,支持 JSON 与限额二进制,不接受前端鉴权头 |
| `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name | | `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 | | `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 |
| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 | | `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 |
@@ -35,6 +35,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
## 前端与发布 ## 前端与发布
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;当前预览版通过 Host 连接已在 `127.0.0.1:8000` 启动的 AI Core,连接失败时返回 CORE_UNAVAILABLE。随机端口、临时令牌和自动管理进程仍属于后续 Sidecar 交付。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page``feature-header``panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。 Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;Host 自动启动受认证的 Core,使用随机端口和管道交换会话;Core 不可用时返回稳定错误码。SSE 使用 `core_stream`/`core_stream_cancel`,凭据命令提供状态、解锁、手动锁定、改密及原生导入;细节与未完成项见[生产化实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page``feature-header``panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev``cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。 默认 `bundle.active=false`新增 `tauri.bundle.conf.json` 配置 Core 资源及 NSIS 目标,尚无自动更新签名安装包。release Host 前必须执行 `uv run --directory backend --group packaging python ../scripts/build-core.py` 生成并嵌入 Core 摘要清单。开发命令为前端 `pnpm dev``cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
@@ -0,0 +1,36 @@
# OpenNexus 生产化实施进度
工作树:`G:\OSProject\NotesAgent-phase3`,分支:`feat/phase3-completion`。用户已授权实施,正式名称为 OpenNexus。保留旧应用 identifier、内部包名和数据目录约定,避免更名导致旧数据不可见。个人 Vault 的已有改动不属于本次交付。
**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 AE 的完整验收。
## 已实现的链路
- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。
- 常规 JSON/二进制及 SSE 请求经原生命令进入 Core。SSE 支持游标、跨 UTF-8 分片及取消;每次流累计不超过 64 MiB,最多 16 个并发流。二进制上传暂为 64 MiB,尚未满足原媒体页面 128 MiB 的完整能力。
- Core 可由 PyInstaller 构建为带运行时的 onedir。构建脚本生成 SHA-256 清单;release Host 编译时嵌入清单,启动前验证文件集合与摘要,拒绝链接、额外文件和篡改。新增 NSIS 资源配置,但尚未生成或验证签名安装包。
- Stronghold 采用 Argon2id 口令派生和单文件快照;解锁期间 Store 中仍保存 AEAD 密文。提供状态、解锁、手动锁定、改密和原生选择旧库导入命令,不提供 WebView 通用明文读取命令。
- Fernet 迁移先验证所有条目与冲突,备份密文和封装主密钥,持久化新库后重新打开逐条比较,最后切换所有权标记。旧文件保留,旧 Python 凭据 API 检测标记后拒绝读写。Core 凭据解析经 Host 管道,不在桌面 Core 使用环境变量凭据回退。
- 同步服务按已确认 offset 修复磁盘超前,磁盘不足时拒绝续传;对象上传与校验使用流;下载先验证完整摘要再响应。完成回执支持重复确认且不重复计费。过期上传每分钟清理,CLI 提供独立清理入口;启动入口支持共享 staging 的两个 worker。ready 检查数据库、staging 和对象存储,429 带 Retry-After。
## 已执行的检查
- 后端全量复跑 894 项测试通过,测试数据使用隔离目录。
- 前端现有 91 个文件、495 项测试通过;新增流式通道测试后,针对性 4 个文件、8 项测试通过;前端类型检查和生产构建通过。构建仍有部分既有大分块警告。
- Rust 15 项库测试、Core 清单完整性测试通过;两个真实 Python Core 集成测试通过,其中一个完整执行 HTTP 凭据写入、管道分发和 Stronghold 解析,确认响应与 Core 目录不泄露秘密。
- Rust desktop 全目标 Clippy `-D warnings` 通过;工具链为 Windows GNU 1.98.1,不能作为 MSVC 发布证据。
- 同步服务 23 项隔离 SQLite 测试通过,包含 100 次重复 complete。不是 PostgreSQL/MinIO 双 worker 压测。
## 尚未完成的工程和验收
| 范围 | 尚需完成 |
| --- | --- |
| Sidecar | AI 笔记读写接入当前 Rust Vault;完整路由 DTO 与取消提交确认;恢复诊断、更新事务;128 MiB 媒体传输;签名包、干净 VM 和 20 次冷启动 |
| 凭据 | OS 锁屏联动;备份恢复与用户确认清除旧库;迁移所有边界故障注入;按真实扩展身份绑定域许可 |
| OS 沙箱 | 平台隔离实现、Host broker、资源预算与恶意二进制验证;当前未启用无沙箱第三方进程 |
| Rust 扩展管理 | 签名验证、ZIP 安全边界、持久安装/升级/回滚事务、旧包迁移、撤回与运行时生命周期 |
| 同步客户端 | Rust push/pull、持久 cursor/inbox、附件续传、绑定隔离、冲突保留与解决 UI、默认/可选数据分类 |
| 同步生产服务 | 真 PostgreSQL/MinIO 两 worker 竞争与负载验证;部署、最小权限、备份恢复、指标与运行手册 |
| 发布 | 统一逐 ID 验收 runnerMSVC 签名安装更新包、SBOM/依赖扫描;两台独立设备 E-01–05 |
Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写入口在 desktop 模式明确拒绝,不能据此宣称 AI 笔记工作流完成。Host `sync/extensions` 能力仍为 false。缺少实机与签名环境不构成其余普通工程尚未完成的理由。
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171717" /> <meta name="theme-color" content="#171717" />
<title>NotesAgent</title> <title>OpenNexus</title>
</head> </head>
<body> <body>
<div id="app"></div> <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 } rfd = { version = "0.15", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
base64 = { version = "0.22", 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] [build-dependencies]
tauri-build = { version = "2", optional = true , features = [] } 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() { 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")] #[cfg(feature = "desktop")]
tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
tauri_build::AppManifest::new().commands(&[ tauri_build::AppManifest::new().commands(&[
"host_capabilities", "host_capabilities",
"credentials_status",
"credentials_unlock",
"credentials_lock",
"credentials_change_password",
"credentials_import",
"core_request", "core_request",
"core_stream",
"core_stream_cancel",
"editor_capabilities", "editor_capabilities",
"workspace_choose", "workspace_choose",
"workspace_open", "workspace_open",
@@ -8,7 +8,14 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"allow-host-capabilities", "allow-host-capabilities",
"allow-credentials-status",
"allow-credentials-unlock",
"allow-credentials-lock",
"allow-credentials-change-password",
"allow-credentials-import",
"allow-core-request", "allow-core-request",
"allow-core-stream",
"allow-core-stream-cancel",
"allow-workspace-choose", "allow-workspace-choose",
"allow-workspace-open", "allow-workspace-open",
"allow-workspace-tree", "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,可独立执行破坏性故障测试。 //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
pub mod core;
pub mod credentials;
pub mod recent; pub mod recent;
mod runtime_compat;
pub mod workspace; pub mod workspace;
+328 -20
View File
@@ -3,17 +3,24 @@
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; 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::recent::{RecentVault, RecentVaultStore};
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
use zeroize::Zeroizing;
#[derive(Default)] #[derive(Default)]
struct Host { struct Host {
workspace: Mutex<Option<Workspace>>, workspace: Mutex<Option<Workspace>>,
recent: Mutex<Option<RecentVaultStore>>, 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 { fn info(ws: &Workspace) -> RecentVault {
@@ -39,8 +46,14 @@ fn with_workspace<T>(
} }
#[tauri::command] #[tauri::command]
fn host_capabilities() -> serde_json::Value { fn host_capabilities(host: State<'_, Host>) -> serde_json::Value {
serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"}) 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)] #[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") media_type == "application/json" || media_type.ends_with("+json")
} }
#[cfg(test)]
fn core_url(path: &str) -> Result<String, String> { fn core_url(path: &str) -> Result<String, String> {
if (!path.starts_with("/api/") && path != "/api" && path != "/health") notesagent_host::core::checked_url(8000, path)
|| path.contains("..")
|| path.contains(['\r', '\n'])
{
return Err("CORE_PATH_DENIED".into());
}
Ok(format!("http://127.0.0.1:8000{path}"))
} }
#[cfg(test)] #[cfg(test)]
@@ -108,14 +116,28 @@ mod core_proxy_tests {
} }
} }
/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。 /// Authenticated process-local transport; session headers are owned by Rust.
#[tauri::command] #[tauri::command]
async fn core_request( async fn core_request(
method: String, method: String,
path: String, path: String,
body: Option<serde_json::Value>, 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> { ) -> 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 = let method =
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?; reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
if !matches!( if !matches!(
@@ -130,16 +152,47 @@ async fn core_request(
} }
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build() .build()
.map_err(|_| "CORE_CLIENT_ERROR")?; .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 { if let Some(value) = body {
request = request.json(&value); request = request.json(&value);
} }
if let Some(value) = authorization { if let Some(encoded) = body_base64 {
request = request.header(reqwest::header::AUTHORIZATION, value); if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
return Err("CORE_REQUEST_TOO_LARGE".into());
} }
let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; 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);
}
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 status = response.status().as_u16();
let content_type = response let content_type = response
.headers() .headers()
@@ -153,10 +206,13 @@ async fn core_request(
{ {
return Err("CORE_RESPONSE_TOO_LARGE".into()); return Err("CORE_RESPONSE_TOO_LARGE".into());
} }
let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?; let mut bytes = Vec::new();
if bytes.len() > MAX_CORE_RESPONSE_BYTES { 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()); return Err("CORE_RESPONSE_TOO_LARGE".into());
} }
bytes.extend_from_slice(&chunk);
}
let (body, body_base64) = if is_json_content_type(&content_type) { let (body, body_base64) = if is_json_content_type(&content_type) {
( (
String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?, String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?,
@@ -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] #[tauri::command]
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> { fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
app.state::<tauri::menu::MenuItem<tauri::Wry>>() app.state::<tauri::menu::MenuItem<tauri::Wry>>()
@@ -314,6 +546,65 @@ fn main() {
.lock() .lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = .map_err(|_| std::io::Error::other("HOST_BUSY"))? =
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); 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(()) Ok(())
}) })
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
@@ -330,7 +621,14 @@ fn main() {
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
host_capabilities, host_capabilities,
credentials_status,
credentials_unlock,
credentials_lock,
credentials_change_password,
credentials_import,
core_request, core_request,
core_stream,
core_stream_cancel,
editor_capabilities, editor_capabilities,
workspace_choose, workspace_choose,
workspace_open, workspace_open,
@@ -343,6 +641,16 @@ fn main() {
workspace_delete, workspace_delete,
workspace_mkdir workspace_mkdir
]) ])
.run(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("桌面 Host 启动失败"); .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", "$schema": "https://schema.tauri.app/config/2",
"productName": "NotesAgent Preview", "productName": "OpenNexus",
"version": "0.3.0-alpha.1", "version": "0.3.0-alpha.1",
"identifier": "cc.kronecker.notesagent", "identifier": "cc.kronecker.notesagent",
"build": { "build": {
@@ -10,9 +10,9 @@
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "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": { "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"] "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'), logs: t('运行日志', 'Operation Logs'),
media: t('音视频转写', 'Media Transcription'), media: t('音视频转写', 'Media Transcription'),
} }
return titles[name] || 'NotesAgent' return titles[name] || 'OpenNexus'
}) })
const currentFileName = computed(() => { const currentFileName = computed(() => {
@@ -63,7 +63,7 @@ function toggleFromTitlebar(event: MouseEvent) {
</span> </span>
</div> </div>
<div class="titlebar-center" data-tauri-drag-region> <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>
<div class="titlebar-right"> <div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')"> <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> <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> <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-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> <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> <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> </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> <footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset> </fieldset>
</form> </form>
+23 -4
View File
@@ -2,17 +2,36 @@
import ActionDialog from '@/components/common/ActionDialog.vue' import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog' import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = 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 { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n' import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue' import FilePicker from '@/components/common/FilePicker.vue'
const route = useRoute() const route = useRoute()
const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission() const submission = createMediaSubmission()
const updateExisting = ref(false) const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([]) const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null) 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 file = ref<File | null>(null)
const reference = ref<File | null>(null) const reference = ref<File | null>(null)
const matchResult = ref('') const matchResult = ref('')
@@ -62,7 +81,7 @@ async function action(work: () => Promise<void>) {
async function submit() { async function submit() {
if (!file.value) return if (!file.value) return
await action(async () => { 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.')) 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 = {} let terms = {}
if (terminology.value.trim()) { 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> <p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
</details> </details>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" /> <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> <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"> <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" /> <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"> <article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header> <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')" /> <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> <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.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> <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 ModelRoutingSettings from './ModelRoutingSettings.vue'
import LocalModelSettings from './LocalModelSettings.vue' import LocalModelSettings from './LocalModelSettings.vue'
import UsageCard from './UsageCard.vue' import UsageCard from './UsageCard.vue'
import CredentialVaultSettings from './CredentialVaultSettings.vue'
import { isDesktop } from '@/services/platform/desktop'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme' 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> <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" /> <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-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> <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="entry-container">
<div class="brand-section"> <div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div> <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> <p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div> </div>
+1 -1
View File
@@ -94,7 +94,7 @@ router.beforeEach((to) => {
}) })
export function updateDocumentTitle(to = router.currentRoute.value) { export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent' const baseTitle = 'OpenNexus'
const titles: Record<string, string> = { const titles: Record<string, string> = {
logs: t('运行日志', 'Operation logs'), logs: t('运行日志', 'Operation logs'),
media: t('音视频转写', 'Media Transcription'), media: t('音视频转写', 'Media Transcription'),
+15 -5
View File
@@ -2,7 +2,7 @@ import type { ApiError, ErrorResponse } from '@/contracts'
import { hostInvoke, isDesktop } from './platform/desktop' import { hostInvoke, isDesktop } from './platform/desktop'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。 // 所有 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 { interface DesktopCoreResponse {
status: number status: number
@@ -82,12 +82,22 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try { try {
if (isDesktop()) { 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', { const response = await hostInvoke<DesktopCoreResponse>('core_request', {
method: rest.method ?? 'GET', method: rest.method ?? 'GET',
path: `${parsed.pathname}${parsed.search}`, path: `${parsed.pathname}${parsed.search}`,
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined, 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 >= 200 && response.status < 300) {
if (response.status === 204) return undefined as T 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 = { export const apiClient = {
postBinary<T>(path: string, body: Blob) { postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } }) return request<T>(path, { method: 'POST', body, headers })
}, },
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) { get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' }) return request<T>(path, { ...options, method: 'GET' })
+5
View File
@@ -1,5 +1,6 @@
import { apiClient, resolveApiUrl } from './apiClient' import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n' 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 Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob { 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`), 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)}`), purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File, idempotencyKey?: string) { 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)}`), { 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, 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 { resolveApiUrl } from './apiClient'
import { isDesktop } from './platform/desktop'
import { coreStream } from './platform/coreStream'
export type SseEventHandler = ( export type SseEventHandler = (
event: string, event: string,
@@ -47,12 +49,13 @@ export class SseClient {
headers['Last-Event-ID'] = lastEventId headers['Last-Event-ID'] = lastEventId
} }
const resp = await fetch(resolveApiUrl(url), { const init: RequestInit = {
method, method,
headers, headers,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal, signal: this.controller.signal,
}) }
const resp = isDesktop() ? await coreStream(url, init) : await fetch(resolveApiUrl(url), init)
if (!resp.ok || !resp.body) { if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`) 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, theme_id: t.theme_id,
name: t.name, name: t.name,
version: t.version, version: t.version,
author: 'NotesAgent 团队', author: 'OpenNexus 团队',
description: t.description, description: t.description,
is_dark: t.is_dark, is_dark: t.is_dark,
builtin: true, builtin: true,
@@ -30,7 +30,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id, theme_id: t.theme_id,
name: t.name, name: t.name,
version: t.version, version: t.version,
author: 'NotesAgent 团队', author: 'OpenNexus 团队',
description: t.description, description: t.description,
min_app_version: '0.1.0', min_app_version: '0.1.0',
is_dark: t.is_dark, 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 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(): def main():
parser = argparse.ArgumentParser() 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") parser.add_argument("--username")
args = parser.parse_args() args = parser.parse_args()
url = os.environ["SYNC_DATABASE_URL"] url = os.environ["SYNC_DATABASE_URL"]
@@ -24,8 +33,11 @@ def main():
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): ")) db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
elif args.command == "serve": elif args.command == "serve":
import uvicorn 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("sync_server.__main__:application", factory=True, workers=args.workers,
uvicorn.run(app, host="0.0.0.0", port=8080, access_log=False) 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__": if __name__ == "__main__":
+117 -13
View File
@@ -4,11 +4,17 @@ import hashlib
import json import json
import secrets import secrets
import time import time
import os
import tempfile
import asyncio
from contextlib import asynccontextmanager
import logging
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Header, Query, Request from fastapi import FastAPI, Header, Query, Request
from fastapi.exceptions import RequestValidationError 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 .database import Database, password_hash, row, rows, run
from .models import Commit, Login, Refresh, Upload, VaultCreate 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): def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=time.time):
db.migrate() db.migrate()
staging.mkdir(parents=True, exist_ok=True) 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.state.database = db
@app.exception_handler(SyncError) @app.exception_handler(SyncError)
async def error(_request, exc): 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) @app.exception_handler(RequestValidationError)
async def invalid(_request, _exc): async def invalid(_request, _exc):
@@ -66,11 +96,40 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@app.get("/ready") def readiness_probe():
def ready():
with db.transaction() as conn: with db.transaction() as conn:
if row(conn, "SELECT version FROM schema_version")["version"] != 1: if row(conn, "SELECT version FROM schema_version")["version"] != 1:
raise SyncError(503, "SCHEMA_INCOMPATIBLE") raise SyncError(503, "SCHEMA_INCOMPATIBLE")
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} return {"status": "ready", "schema": 1}
@app.get("/sync/v1/handshake") @app.get("/sync/v1/handshake")
@@ -171,10 +230,26 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
raise SyncError(404, "UPLOAD_EXPIRED") raise SyncError(404, "UPLOAD_EXPIRED")
return upload 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}") @app.get("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
def upload_status(vault_id: str, upload_id: str, authorization: str = Header(default="")): def upload_status(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn: with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization) upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]} return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]}
@app.put("/sync/v1/vaults/{vault_id}/uploads/{upload_id}") @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") raise SyncError(413, "CHUNK_TOO_LARGE")
with db.transaction() as conn: with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization) upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
if offset != upload["offset_bytes"]: if offset != upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]}) raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]})
if offset + len(data) > upload["size"]: 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") @app.post("/sync/v1/vaults/{vault_id}/uploads/{upload_id}/complete")
def complete_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")): def complete_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn: 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) upload = authorized_upload(conn, vault_id, upload_id, authorization)
data = (staging / upload_id).read_bytes() path = reconcile_staging(upload)
if len(data) != upload["size"] or len(data) != upload["offset_bytes"] or hashlib.sha256(data).hexdigest() != upload["hash"]: 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") 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"]) exists = row(conn, "SELECT hash FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=upload["hash"])
if not exists: if not exists:
objects.put(vault_id + "/" + upload["hash"], data) 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=len(data), now=int(clock())) 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=len(data), v=vault_id) 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) run(conn, "DELETE FROM uploads WHERE id=:id", id=upload_id)
(staging / upload_id).unlink(missing_ok=True) (staging / upload_id).unlink(missing_ok=True)
return {"complete": True, "content_hash": upload["hash"]} 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) obj = row(conn, "SELECT * FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=content_hash)
if not obj: if not obj:
raise SyncError(404, "OBJECT_NOT_FOUND") raise SyncError(404, "OBJECT_NOT_FOUND")
data = objects.get(vault_id + "/" + content_hash) expected_size = obj["size"]
if len(data) != obj["size"] or hashlib.sha256(data).hexdigest() != content_hash: # 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") raise SyncError(503, "STORAGE_INTEGRITY")
return Response(data, media_type="application/octet-stream", headers={"ETag": '"' + content_hash + '"', "Cache-Control": "private, no-store"}) checksum.update(chunk)
temporary.write(chunk)
if size != expected_size or checksum.hexdigest() != content_hash:
raise SyncError(503, "STORAGE_INTEGRITY")
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 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 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 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 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: class Database:
def __init__(self, url: str): 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" self.sqlite = self.engine.dialect.name == "sqlite"
def migrate(self): def migrate(self):
with self.transaction() as conn: 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])) conn.execute(text(SCHEMA[0]))
version = conn.execute(text("SELECT version FROM schema_version")).scalar() version = conn.execute(text("SELECT version FROM schema_version")).scalar()
if version not in {None, 1}: 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 hashlib
import os import os
import tempfile import tempfile
import shutil
from contextlib import contextmanager
class DiskObjects: class DiskObjects:
@@ -27,6 +29,30 @@ class DiskObjects:
def get(self, key: str) -> bytes: def get(self, key: str) -> bytes:
return (self.root / key).read_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): def delete(self, key: str):
(self.root / key).unlink(missing_ok=True) (self.root / key).unlink(missing_ok=True)
@@ -34,7 +60,10 @@ class DiskObjects:
class S3Objects: class S3Objects:
def __init__(self, endpoint: str, bucket: str): def __init__(self, endpoint: str, bucket: str):
import boto3 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 self.bucket = bucket
def put(self, key: str, data: bytes): def put(self, key: str, data: bytes):
@@ -46,5 +75,18 @@ class S3Objects:
with response["Body"] as stream: with response["Body"] as stream:
return stream.read() 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): def delete(self, key: str):
self.client.delete_object(Bucket=self.bucket, Key=key) 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