feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Notes Agent(暂命名) 团队开发说明
|
||||
# OpenNexus 团队开发说明
|
||||
|
||||
> 第二阶段收尾(开发分支,2026-09-07):标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
> 第三阶段分支状态(2026-09-07):已加入 Tauri/Rust 原生 Vault 预览、Sync v1 服务原型、签名社区目录原型和七类社区入口。完整 Sidecar、Stronghold、生产插件隔离、同步客户端、升级回滚及三平台发布门禁仍未交付;详见[第三阶段实施与验收记录](docs/development/第三阶段实施与验收记录.md)。
|
||||
|
||||
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
|
||||
OpenNexus 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
|
||||
|
||||
截至 2026-09-06,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
|
||||
|
||||
> 正式名称:OpenNexus(2026-09-08)。旧应用标识 `cc.kronecker.notesagent`、数据库/凭据路径和协议标识保留兼容,不因品牌更名创建新数据目录。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
|
||||
@@ -32,7 +32,7 @@ class Settings:
|
||||
def get_settings() -> Settings:
|
||||
data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data")))
|
||||
return Settings(
|
||||
name=os.getenv("APP_NAME", "Notes Agent AI Core"),
|
||||
name=os.getenv("APP_NAME", "OpenNexus AI Core"),
|
||||
version=os.getenv("APP_VERSION", "0.1.0"),
|
||||
environment=os.getenv("APP_ENVIRONMENT", "development"),
|
||||
host=os.getenv("APP_HOST", "127.0.0.1"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
HostCredentialStore,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +22,7 @@ class ApplicationContainer:
|
||||
providers: ProviderRegistry
|
||||
provider_factory: ProviderFactory
|
||||
model_routing: ModelRoutingService
|
||||
credentials: EncryptedCredentialStore
|
||||
credentials: EncryptedCredentialStore | HostCredentialStore
|
||||
tools: ToolRegistry
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
@@ -32,9 +33,9 @@ class ApplicationContainer:
|
||||
|
||||
def build_container() -> ApplicationContainer:
|
||||
settings = get_settings()
|
||||
credentials = EncryptedCredentialStore()
|
||||
credentials = HostCredentialStore() if settings.environment == "desktop" else EncryptedCredentialStore()
|
||||
provider_factory = ProviderFactory(
|
||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||
credentials if settings.environment == "desktop" else ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||
)
|
||||
providers = ProviderRegistry(provider_factory)
|
||||
providers.register(
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
@@ -24,6 +25,37 @@ class CredentialResolver(Protocol):
|
||||
def resolve(self, credential_id: str | None) -> str | None: ...
|
||||
|
||||
|
||||
class HostCredentialStore:
|
||||
"""Desktop-only adapter. It cannot fall back to Fernet or environment keys."""
|
||||
@staticmethod
|
||||
def _call(method, **params):
|
||||
from app.host_bridge import active
|
||||
if active is None:
|
||||
raise CredentialStoreError("HOST_UNAVAILABLE")
|
||||
try:
|
||||
return active.call("credentials." + method, **params)
|
||||
except RuntimeError as exc:
|
||||
raise CredentialStoreError(str(exc)) from None
|
||||
|
||||
def resolve(self, credential_id):
|
||||
return self._call("resolve", id=credential_id) if credential_id else None
|
||||
|
||||
def has(self, credential_id):
|
||||
return bool(self._call("has", id=credential_id))
|
||||
|
||||
def put(self, credential_id, secret):
|
||||
self._call("put", id=credential_id, secret=secret)
|
||||
|
||||
def delete(self, credential_id):
|
||||
return bool(self._call("delete", id=credential_id))
|
||||
|
||||
def delete_many(self, credential_ids):
|
||||
return set(self._call("delete_many", ids=credential_ids))
|
||||
|
||||
def move_many(self, replacements):
|
||||
self._call("move_many", replacements=replacements)
|
||||
|
||||
|
||||
def validate_provider_credential_id(credential_id: str | None) -> None:
|
||||
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
||||
|
||||
@@ -62,6 +94,33 @@ class EncryptedCredentialStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@contextmanager
|
||||
def _operation_lock(self):
|
||||
with self._lock:
|
||||
key_path, _ = self._paths()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with (key_path.parent / ".migration.lock").open("a+b") as stream:
|
||||
stream.seek(0)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
raise CredentialStoreError("MIGRATION_SOURCE_BUSY") from None
|
||||
try:
|
||||
if (key_path.parent / ".opennexus-owner.json").exists():
|
||||
raise CredentialStoreError("CREDENTIAL_OWNER_DESKTOP")
|
||||
yield
|
||||
finally:
|
||||
stream.seek(0)
|
||||
if os.name == "nt":
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@staticmethod
|
||||
def _validate_id(credential_id: str) -> None:
|
||||
if not _CREDENTIAL_ID.fullmatch(credential_id):
|
||||
@@ -155,7 +214,7 @@ class EncryptedCredentialStore:
|
||||
self._validate_id(credential_id)
|
||||
if not secret:
|
||||
raise CredentialStoreError("Credential secret cannot be empty.")
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
tokens = self._read_tokens()
|
||||
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
|
||||
tokens[credential_id] = token
|
||||
@@ -165,7 +224,7 @@ class EncryptedCredentialStore:
|
||||
if not credential_id:
|
||||
return None
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
token = self._read_tokens().get(credential_id)
|
||||
if token is None:
|
||||
return None
|
||||
@@ -176,12 +235,12 @@ class EncryptedCredentialStore:
|
||||
|
||||
def has(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
return credential_id in self._read_tokens()
|
||||
|
||||
def delete(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
tokens = self._read_tokens()
|
||||
removed = tokens.pop(credential_id, None) is not None
|
||||
if removed:
|
||||
@@ -193,7 +252,7 @@ class EncryptedCredentialStore:
|
||||
|
||||
for credential_id in credential_ids:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
tokens = self._read_tokens()
|
||||
removed = {
|
||||
credential_id
|
||||
@@ -212,7 +271,7 @@ class EncryptedCredentialStore:
|
||||
for old_id, new_id in replacements.items():
|
||||
self._validate_id(old_id)
|
||||
self._validate_id(new_id)
|
||||
with self._lock:
|
||||
with self._operation_lock():
|
||||
tokens = self._read_tokens()
|
||||
changed = False
|
||||
for old_id, new_id in replacements.items():
|
||||
|
||||
@@ -11,6 +11,8 @@ def web_vault_ownership():
|
||||
"""与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。"""
|
||||
from app.config import get_settings
|
||||
from app.errors import ApiError
|
||||
if get_settings().environment == 'desktop':
|
||||
raise ApiError(409, 'WORKSPACE_OWNER_DESKTOP', '桌面笔记写入必须通过 Rust Host')
|
||||
root = get_settings().vault_path
|
||||
managed = root / '.ainote'
|
||||
if managed.is_symlink() or (hasattr(managed, 'is_junction') and managed.is_junction()):
|
||||
|
||||
@@ -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())
|
||||
@@ -25,6 +25,9 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.4,<9.0",
|
||||
]
|
||||
packaging = [
|
||||
"pyinstaller>=6.16,<7",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
|
||||
@@ -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())
|
||||
@@ -258,7 +258,7 @@ def test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive(
|
||||
def test_service_status() -> None:
|
||||
response = asyncio.run(service_status())
|
||||
|
||||
assert response.name == "Notes Agent AI Core"
|
||||
assert response.name == "OpenNexus AI Core"
|
||||
assert response.status == "ok"
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No
|
||||
assert store.resolve("deepseek") is None
|
||||
|
||||
|
||||
def test_migrated_fernet_owner_blocks_old_reads_and_writes() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("fixture", "test-secret")
|
||||
directory = get_settings().data_dir / "credentials"
|
||||
before = (directory / "credentials.json").read_bytes()
|
||||
(directory / ".opennexus-owner.json").write_text('{"state":"switched"}')
|
||||
for operation in [lambda: store.resolve("fixture"), lambda: store.has("fixture"),
|
||||
lambda: store.put("fixture", "changed"), lambda: store.delete("fixture")]:
|
||||
with pytest.raises(CredentialStoreError, match="CREDENTIAL_OWNER_DESKTOP"):
|
||||
operation()
|
||||
assert (directory / "credentials.json").read_bytes() == before
|
||||
|
||||
|
||||
def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("plugin.first", "first")
|
||||
|
||||
@@ -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()
|
||||
Generated
+92
@@ -6,6 +6,15 @@ resolution-markers = [
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
@@ -1011,6 +1020,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib"
|
||||
version = "3.11.1"
|
||||
@@ -1110,6 +1131,9 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
packaging = [
|
||||
{ name = "pyinstaller" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
@@ -1131,6 +1155,7 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.4,<9.0" }]
|
||||
packaging = [{ name = "pyinstaller", specifier = ">=6.16,<7" }]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -1308,6 +1333,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2024.8.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
@@ -1568,6 +1602,46 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.22.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyinstaller-hooks-contrib" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2026.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
@@ -1627,6 +1701,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1832,6 +1915,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "84.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
# NotesAgent 文档索引
|
||||
# OpenNexus 文档索引
|
||||
|
||||
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
|
||||
|
||||
当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录的可测试原型;它仍缺少 Sidecar、Stronghold、生产插件隔离、完整同步客户端和发布门禁,不能作为第三阶段发布候选。状态与证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。
|
||||
当前第三阶段分支已实现 Tauri/Rust 原生 Vault、Sync v1 和签名社区目录原型,并开始接入受认证的 Sidecar 与 Stronghold。生产插件隔离、完整同步客户端和发布门禁仍未完成,不能作为发布候选。最新状态见[OpenNexus 生产化实施进度](development/OpenNexus生产化实施进度-2026-09-08.md),历史证据见[第三阶段实施与验收记录](development/第三阶段实施与验收记录.md)。
|
||||
|
||||
仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 第三阶段五项生产化能力:工程规划与验收目标
|
||||
|
||||
日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`。状态:**仅规划,五项均未通过生产验收;本文不交付功能、安装包或运行证据。**
|
||||
日期:2026-09-08。审计基线:`2010778780fa7267557a7302132e63ac533d43ab`,分支 `feat/phase3-completion`。初始规划提交:`2c4287e`。用户随后授权全量实施,并确定正式名称为 **OpenNexus**。状态:**实施中,五项均未通过完整生产验收**;实际证据见[实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。
|
||||
|
||||
## 1. 范围、文档优先级与交付口径
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
“通过”要求实现、自动化报告、平台实测和恢复演练同时满足。旧记录中的测试数量不继承为本计划证据。五项完成不代表多窗口、全部社区内容能力、OCR/音频质量等第三阶段其他退出项完成。
|
||||
|
||||
本次仅允许文档提交。实施期间测试也只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。
|
||||
初始规划轮仅允许文档提交;后续实施已获用户授权。实施期间测试只使用自动生成的临时 Vault、测试密钥和隔离服务,不读取个人笔记、凭据或生产数据库。
|
||||
|
||||
## 2. 现状审计与差距
|
||||
|
||||
@@ -256,7 +256,7 @@ runner输出JUnit、逐ID JSON、耗时/峰值内存、进程树/拒绝访问计
|
||||
|
||||
## 14. 非目标与仍需产品确认的事项
|
||||
|
||||
明确非目标:本次任务功能实现;移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。
|
||||
明确非目标:移动端、多人共享/CRDT、E2EE与跨设备秘密同步、历史GC、服务跨主机水平扩容、任意远程代码热注入、未通过平台的无沙箱例外;自动下载全部模型/CUDA;替代第三阶段其他UI/内容质量验收。通知推送可后续加入,轮询承担本次正确性与时延门禁。
|
||||
|
||||
以下在P0由产品/安全/运维共同确认,当前采用表中工程默认值推进设计,不把未答复当批准:
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
| 命令 | 参数 / 返回 |
|
||||
| --- | --- |
|
||||
| `host_capabilities` | protocol=1,workspace/core=true;sync/credentials/extensions=false |
|
||||
| `core_request` | 仅代理固定的 `http://127.0.0.1:8000/health` 与 `/api/*` JSON 请求;路径、方法和超时由 Host 限制 |
|
||||
| `host_capabilities` | protocol=1,workspace/credentials=true;core 随进程状态返回;sync/extensions=false |
|
||||
| `core_request` | 代理认证 Sidecar 的 `/health` 与 `/api/*`;Host 注入会话和代际,支持 JSON 与限额二进制,不接受前端鉴权头 |
|
||||
| `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
|
||||
| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 |
|
||||
| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 |
|
||||
@@ -35,6 +35,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
|
||||
|
||||
## 前端与发布
|
||||
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;当前预览版通过 Host 连接已在 `127.0.0.1:8000` 启动的 AI Core,连接失败时返回 CORE_UNAVAILABLE。随机端口、临时令牌和自动管理进程仍属于后续 Sidecar 交付。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page`、`feature-header`、`panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;Host 自动启动受认证的 Core,使用随机端口和管道交换会话;Core 不可用时返回稳定错误码。SSE 使用 `core_stream`/`core_stream_cancel`,凭据命令提供状态、解锁、手动锁定、改密及原生导入;细节与未完成项见[生产化实施记录](../development/OpenNexus生产化实施进度-2026-09-08.md)。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page`、`feature-header`、`panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
|
||||
|
||||
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
|
||||
默认 `bundle.active=false`,新增 `tauri.bundle.conf.json` 配置 Core 资源及 NSIS 目标,尚无自动更新或签名安装包。release Host 前必须执行 `uv run --directory backend --group packaging python ../scripts/build-core.py` 生成并嵌入 Core 摘要清单。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# OpenNexus 生产化实施进度
|
||||
|
||||
工作树:`G:\OSProject\NotesAgent-phase3`,分支:`feat/phase3-completion`。用户已授权实施,正式名称为 OpenNexus。保留旧应用 identifier、内部包名和数据目录约定,避免更名导致旧数据不可见。个人 Vault 的已有改动不属于本次交付。
|
||||
|
||||
**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 A–E 的完整验收。
|
||||
|
||||
## 已实现的链路
|
||||
|
||||
- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。
|
||||
- 常规 JSON/二进制及 SSE 请求经原生命令进入 Core。SSE 支持游标、跨 UTF-8 分片及取消;每次流累计不超过 64 MiB,最多 16 个并发流。二进制上传暂为 64 MiB,尚未满足原媒体页面 128 MiB 的完整能力。
|
||||
- Core 可由 PyInstaller 构建为带运行时的 onedir。构建脚本生成 SHA-256 清单;release Host 编译时嵌入清单,启动前验证文件集合与摘要,拒绝链接、额外文件和篡改。新增 NSIS 资源配置,但尚未生成或验证签名安装包。
|
||||
- Stronghold 采用 Argon2id 口令派生和单文件快照;解锁期间 Store 中仍保存 AEAD 密文。提供状态、解锁、手动锁定、改密和原生选择旧库导入命令,不提供 WebView 通用明文读取命令。
|
||||
- Fernet 迁移先验证所有条目与冲突,备份密文和封装主密钥,持久化新库后重新打开逐条比较,最后切换所有权标记。旧文件保留,旧 Python 凭据 API 检测标记后拒绝读写。Core 凭据解析经 Host 管道,不在桌面 Core 使用环境变量凭据回退。
|
||||
- 同步服务按已确认 offset 修复磁盘超前,磁盘不足时拒绝续传;对象上传与校验使用流;下载先验证完整摘要再响应。完成回执支持重复确认且不重复计费。过期上传每分钟清理,CLI 提供独立清理入口;启动入口支持共享 staging 的两个 worker。ready 检查数据库、staging 和对象存储,429 带 Retry-After。
|
||||
|
||||
## 已执行的检查
|
||||
|
||||
- 后端全量复跑 894 项测试通过,测试数据使用隔离目录。
|
||||
- 前端现有 91 个文件、495 项测试通过;新增流式通道测试后,针对性 4 个文件、8 项测试通过;前端类型检查和生产构建通过。构建仍有部分既有大分块警告。
|
||||
- Rust 15 项库测试、Core 清单完整性测试通过;两个真实 Python Core 集成测试通过,其中一个完整执行 HTTP 凭据写入、管道分发和 Stronghold 解析,确认响应与 Core 目录不泄露秘密。
|
||||
- Rust desktop 全目标 Clippy `-D warnings` 通过;工具链为 Windows GNU 1.98.1,不能作为 MSVC 发布证据。
|
||||
- 同步服务 23 项隔离 SQLite 测试通过,包含 100 次重复 complete。不是 PostgreSQL/MinIO 双 worker 压测。
|
||||
|
||||
## 尚未完成的工程和验收
|
||||
|
||||
| 范围 | 尚需完成 |
|
||||
| --- | --- |
|
||||
| Sidecar | AI 笔记读写接入当前 Rust Vault;完整路由 DTO 与取消提交确认;恢复诊断、更新事务;128 MiB 媒体传输;签名包、干净 VM 和 20 次冷启动 |
|
||||
| 凭据 | OS 锁屏联动;备份恢复与用户确认清除旧库;迁移所有边界故障注入;按真实扩展身份绑定域许可 |
|
||||
| OS 沙箱 | 平台隔离实现、Host broker、资源预算与恶意二进制验证;当前未启用无沙箱第三方进程 |
|
||||
| Rust 扩展管理 | 签名验证、ZIP 安全边界、持久安装/升级/回滚事务、旧包迁移、撤回与运行时生命周期 |
|
||||
| 同步客户端 | Rust push/pull、持久 cursor/inbox、附件续传、绑定隔离、冲突保留与解决 UI、默认/可选数据分类 |
|
||||
| 同步生产服务 | 真 PostgreSQL/MinIO 两 worker 竞争与负载验证;部署、最小权限、备份恢复、指标与运行手册 |
|
||||
| 发布 | 统一逐 ID 验收 runner;MSVC 签名安装更新包、SBOM/依赖扫描;两台独立设备 E-01–05 |
|
||||
|
||||
Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写入口在 desktop 模式明确拒绝,不能据此宣称 AI 笔记工作流完成。Host `sync/extensions` 能力仍为 false。缺少实机与签名环境不构成其余普通工程尚未完成的理由。
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<title>NotesAgent</title>
|
||||
<title>OpenNexus</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+1079
-17
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,19 @@ tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
||||
rfd = { version = "0.15", optional = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||
base64 = { version = "0.22", optional = true }
|
||||
hmac = { version = "0.12", default-features = false }
|
||||
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
||||
zeroize = { version = "1", default-features = false, features = ["alloc"] }
|
||||
command-group = { version = "5", default-features = false }
|
||||
iota_stronghold = "2.1"
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", optional = true , features = [] }
|
||||
|
||||
# Cryptographic KDFs retain their production work factors in debug/test runs.
|
||||
# Optimize dependencies rather than weakening those factors for local execution.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
fn main() {
|
||||
let manifest = std::path::Path::new("../../.build/sidecar/manifest.json");
|
||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||
let content = if std::env::var("PROFILE").as_deref() == Ok("release") {
|
||||
std::fs::read(manifest)
|
||||
.expect("Build Core with scripts/build-core.py before a release Host")
|
||||
} else {
|
||||
b"{}".to_vec()
|
||||
};
|
||||
std::fs::write(
|
||||
std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("core-manifest.json"),
|
||||
content,
|
||||
)
|
||||
.unwrap();
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
tauri_build::AppManifest::new().commands(&[
|
||||
"host_capabilities",
|
||||
"credentials_status",
|
||||
"credentials_unlock",
|
||||
"credentials_lock",
|
||||
"credentials_change_password",
|
||||
"credentials_import",
|
||||
"core_request",
|
||||
"core_stream",
|
||||
"core_stream_cancel",
|
||||
"editor_capabilities",
|
||||
"workspace_choose",
|
||||
"workspace_open",
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-host-capabilities",
|
||||
"allow-credentials-status",
|
||||
"allow-credentials-unlock",
|
||||
"allow-credentials-lock",
|
||||
"allow-credentials-change-password",
|
||||
"allow-credentials-import",
|
||||
"allow-core-request",
|
||||
"allow-core-stream",
|
||||
"allow-core-stream-cancel",
|
||||
"allow-workspace-choose",
|
||||
"allow-workspace-open",
|
||||
"allow-workspace-tree",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-core-stream"
|
||||
description = "Enables the core_stream command without any pre-configured scope."
|
||||
commands.allow = ["core_stream"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-core-stream"
|
||||
description = "Denies the core_stream command without any pre-configured scope."
|
||||
commands.deny = ["core_stream"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-core-stream-cancel"
|
||||
description = "Enables the core_stream_cancel command without any pre-configured scope."
|
||||
commands.allow = ["core_stream_cancel"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-core-stream-cancel"
|
||||
description = "Denies the core_stream_cancel command without any pre-configured scope."
|
||||
commands.deny = ["core_stream_cancel"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-credentials-change-password"
|
||||
description = "Enables the credentials_change_password command without any pre-configured scope."
|
||||
commands.allow = ["credentials_change_password"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-credentials-change-password"
|
||||
description = "Denies the credentials_change_password command without any pre-configured scope."
|
||||
commands.deny = ["credentials_change_password"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-credentials-import"
|
||||
description = "Enables the credentials_import command without any pre-configured scope."
|
||||
commands.allow = ["credentials_import"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-credentials-import"
|
||||
description = "Denies the credentials_import command without any pre-configured scope."
|
||||
commands.deny = ["credentials_import"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-credentials-lock"
|
||||
description = "Enables the credentials_lock command without any pre-configured scope."
|
||||
commands.allow = ["credentials_lock"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-credentials-lock"
|
||||
description = "Denies the credentials_lock command without any pre-configured scope."
|
||||
commands.deny = ["credentials_lock"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-credentials-status"
|
||||
description = "Enables the credentials_status command without any pre-configured scope."
|
||||
commands.allow = ["credentials_status"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-credentials-status"
|
||||
description = "Denies the credentials_status command without any pre-configured scope."
|
||||
commands.deny = ["credentials_status"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-credentials-unlock"
|
||||
description = "Enables the credentials_unlock command without any pre-configured scope."
|
||||
commands.allow = ["credentials_unlock"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-credentials-unlock"
|
||||
description = "Denies the credentials_unlock command without any pre-configured scope."
|
||||
commands.deny = ["credentials_unlock"]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
|
||||
|
||||
pub mod core;
|
||||
pub mod credentials;
|
||||
pub mod recent;
|
||||
mod runtime_compat;
|
||||
pub mod workspace;
|
||||
|
||||
+329
-21
@@ -3,17 +3,24 @@
|
||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use notesagent_host::core::CoreSupervisor;
|
||||
use notesagent_host::credentials::CredentialBroker;
|
||||
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Host {
|
||||
workspace: Mutex<Option<Workspace>>,
|
||||
recent: Mutex<Option<RecentVaultStore>>,
|
||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||
credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
||||
streams: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
fn info(ws: &Workspace) -> RecentVault {
|
||||
@@ -39,8 +46,14 @@ fn with_workspace<T>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn host_capabilities() -> serde_json::Value {
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"})
|
||||
fn host_capabilities(host: State<'_, Host>) -> serde_json::Value {
|
||||
let ready = host
|
||||
.core
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|mut core| core.as_mut().map(|c| c.available()))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":false,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"})
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -65,14 +78,9 @@ fn is_json_content_type(content_type: &str) -> bool {
|
||||
media_type == "application/json" || media_type.ends_with("+json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn core_url(path: &str) -> Result<String, String> {
|
||||
if (!path.starts_with("/api/") && path != "/api" && path != "/health")
|
||||
|| path.contains("..")
|
||||
|| path.contains(['\r', '\n'])
|
||||
{
|
||||
return Err("CORE_PATH_DENIED".into());
|
||||
}
|
||||
Ok(format!("http://127.0.0.1:8000{path}"))
|
||||
notesagent_host::core::checked_url(8000, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -108,14 +116,28 @@ mod core_proxy_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。
|
||||
/// Authenticated process-local transport; session headers are owned by Rust.
|
||||
#[tauri::command]
|
||||
async fn core_request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: Option<serde_json::Value>,
|
||||
authorization: Option<String>,
|
||||
body_base64: Option<String>,
|
||||
content_type: Option<String>,
|
||||
idempotency_key: Option<String>,
|
||||
host: State<'_, Host>,
|
||||
) -> Result<CoreResponse, String> {
|
||||
let core = host.core.clone();
|
||||
let core_path = path.clone();
|
||||
let session = tauri::async_runtime::spawn_blocking(move || {
|
||||
core.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("CORE_UNAVAILABLE")?
|
||||
.request_session(&core_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "CORE_UNAVAILABLE")??;
|
||||
let method =
|
||||
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
|
||||
if !matches!(
|
||||
@@ -130,16 +152,47 @@ async fn core_request(
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|_| "CORE_CLIENT_ERROR")?;
|
||||
let mut request = client.request(method, core_url(&path)?);
|
||||
let mut request = client
|
||||
.request(method, &session.url)
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
session.authorization.as_str(),
|
||||
)
|
||||
.header("X-Core-Generation", &session.generation);
|
||||
if let Some(value) = body {
|
||||
request = request.json(&value);
|
||||
}
|
||||
if let Some(value) = authorization {
|
||||
request = request.header(reqwest::header::AUTHORIZATION, value);
|
||||
if let Some(encoded) = body_base64 {
|
||||
if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| "CORE_BODY_INVALID")?;
|
||||
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
let content_type = content_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
if !matches!(content_type, "application/octet-stream" | "application/zip") {
|
||||
return Err("CORE_CONTENT_TYPE_DENIED".into());
|
||||
}
|
||||
request = request
|
||||
.header(reqwest::header::CONTENT_TYPE, content_type)
|
||||
.body(bytes);
|
||||
}
|
||||
let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
if let Some(key) = idempotency_key {
|
||||
if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err("CORE_HEADER_INVALID".into());
|
||||
}
|
||||
request = request.header("Idempotency-Key", key);
|
||||
}
|
||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
let status = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
@@ -153,9 +206,12 @@ async fn core_request(
|
||||
{
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?;
|
||||
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let (body, body_base64) = if is_json_content_type(&content_type) {
|
||||
(
|
||||
@@ -173,6 +229,182 @@ async fn core_request(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn core_stream_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> {
|
||||
if let Some(task) = host
|
||||
.streams
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.remove(&request_id)
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn core_stream(
|
||||
host: State<'_, Host>,
|
||||
request_id: String,
|
||||
path: String,
|
||||
method: String,
|
||||
body: Option<serde_json::Value>,
|
||||
last_event_id: Option<String>,
|
||||
channel: tauri::ipc::Channel<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
uuid::Uuid::parse_str(&request_id).map_err(|_| "CORE_REQUEST_ID_INVALID")?;
|
||||
if !matches!(method.as_str(), "GET" | "POST") {
|
||||
return Err("CORE_METHOD_DENIED".into());
|
||||
}
|
||||
if body
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.to_string().len() > 1024 * 1024)
|
||||
{
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
if last_event_id
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.len() > 128 || s.contains(['\r', '\n']))
|
||||
{
|
||||
return Err("CORE_HEADER_INVALID".into());
|
||||
}
|
||||
let core = host.core.clone();
|
||||
let streams = host.streams.clone();
|
||||
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
|
||||
if running.len() >= 16 || running.contains_key(&request_id) {
|
||||
return Err("CORE_STREAM_LIMIT".into());
|
||||
}
|
||||
let id = request_id.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result: Result<(), String> = async {
|
||||
let session = tauri::async_runtime::spawn_blocking(move || {
|
||||
core.lock().map_err(|_| "HOST_BUSY")?.as_mut().ok_or("CORE_UNAVAILABLE")?.request_session(&path)
|
||||
}).await.map_err(|_| "CORE_UNAVAILABLE")??;
|
||||
let client = reqwest::Client::builder().no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none()).timeout(Duration::from_secs(600))
|
||||
.build().map_err(|_| "CORE_CLIENT_ERROR")?;
|
||||
let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url)
|
||||
.header("Authorization", session.authorization.as_str())
|
||||
.header("X-Core-Generation", session.generation).header("Accept", "text/event-stream");
|
||||
if let Some(body) = body { request = request.json(&body); }
|
||||
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
|
||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
channel.send(serde_json::json!({"kind":"headers","status":response.status().as_u16()})).map_err(|_| "CORE_STREAM_CLOSED")?;
|
||||
let mut size = 0usize;
|
||||
while let Some(bytes) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
|
||||
size = size.saturating_add(bytes.len());
|
||||
if size > MAX_CORE_RESPONSE_BYTES { return Err("CORE_RESPONSE_TOO_LARGE".into()); }
|
||||
for chunk in bytes.chunks(16384) {
|
||||
channel.send(serde_json::json!({"kind":"chunk","data":BASE64_STANDARD.encode(chunk)})).map_err(|_| "CORE_STREAM_CLOSED")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = channel.send(serde_json::json!({"kind":"done"}));
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = channel.send(serde_json::json!({"kind":"error","code":code}));
|
||||
}
|
||||
}
|
||||
if let Ok(mut running) = streams.lock() {
|
||||
running.remove(&id);
|
||||
}
|
||||
});
|
||||
running.insert(request_id, task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String> {
|
||||
let broker = host
|
||||
.credentials
|
||||
.try_lock()
|
||||
.map_err(|_| "CREDENTIALS_BUSY")?;
|
||||
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
|
||||
Ok(serde_json::json!({"locked":broker.is_locked()}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> {
|
||||
let broker = host.credentials.clone();
|
||||
let password = Zeroizing::new(password.into_bytes());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
broker
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.unlock(password)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn credentials_lock(host: State<'_, Host>) -> Result<(), String> {
|
||||
host.credentials
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.lock();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_import(host: State<'_, Host>) -> Result<Option<usize>, String> {
|
||||
let Some(path) = rfd::FileDialog::new()
|
||||
.set_title("选择旧版本的 credentials.json(不会删除原文件)")
|
||||
.add_filter("Fernet credentials", &["json"])
|
||||
.pick_file()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.file_name().and_then(|n| n.to_str()) != Some("credentials.json") {
|
||||
return Err("MIGRATION_SOURCE_INVALID".into());
|
||||
}
|
||||
let directory = path
|
||||
.parent()
|
||||
.ok_or("MIGRATION_SOURCE_INVALID")?
|
||||
.to_path_buf();
|
||||
let broker = host.credentials.clone();
|
||||
let environment_key = std::env::var("APP_CREDENTIAL_MASTER_KEY")
|
||||
.ok()
|
||||
.map(Zeroizing::new);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
broker
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.import_fernet(&directory, environment_key)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_change_password(
|
||||
host: State<'_, Host>,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let broker = host.credentials.clone();
|
||||
let password = Zeroizing::new(password.into_bytes());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
broker
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.change_password(password)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
|
||||
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
|
||||
@@ -314,6 +546,65 @@ fn main() {
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
|
||||
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
|
||||
let credential_state = app.state::<Host>().credentials.clone();
|
||||
*credential_state
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
|
||||
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
|
||||
));
|
||||
let data_dir = app.path().app_data_dir()?.join("core-data");
|
||||
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
|
||||
let core = if cfg!(debug_assertions) {
|
||||
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../backend")
|
||||
.canonicalize()?;
|
||||
let python = backend.join(if cfg!(windows) {
|
||||
".venv/Scripts/python.exe"
|
||||
} else {
|
||||
".venv/bin/python"
|
||||
});
|
||||
CoreSupervisor::new(
|
||||
python,
|
||||
vec!["-m".into(), "app.sidecar".into()],
|
||||
backend,
|
||||
data_dir,
|
||||
)
|
||||
} else {
|
||||
let root = app.path().resource_dir()?.join("core");
|
||||
CoreSupervisor::new(
|
||||
root.join(if cfg!(windows) {
|
||||
"opennexus-core.exe"
|
||||
} else {
|
||||
"opennexus-core"
|
||||
}),
|
||||
vec![],
|
||||
root,
|
||||
data_dir,
|
||||
)
|
||||
.with_bundle_manifest(
|
||||
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
|
||||
)
|
||||
};
|
||||
let core = core.with_broker(Arc::new(move |request| {
|
||||
credential_state
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.dispatch(request)
|
||||
}));
|
||||
*app.state::<Host>()
|
||||
.core
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(core);
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(mut core) = handle.state::<Host>().core.lock() {
|
||||
if let Some(core) = core.as_mut() {
|
||||
let _ = core.start();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_menu_event(|app, event| {
|
||||
@@ -330,7 +621,14 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
host_capabilities,
|
||||
credentials_status,
|
||||
credentials_unlock,
|
||||
credentials_lock,
|
||||
credentials_change_password,
|
||||
credentials_import,
|
||||
core_request,
|
||||
core_stream,
|
||||
core_stream_cancel,
|
||||
editor_capabilities,
|
||||
workspace_choose,
|
||||
workspace_open,
|
||||
@@ -343,6 +641,16 @@ fn main() {
|
||||
workspace_delete,
|
||||
workspace_mkdir
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("桌面 Host 启动失败");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("桌面 Host 启动失败")
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Ok(mut broker) = app.state::<Host>().credentials.lock() {
|
||||
broker.take();
|
||||
}
|
||||
if let Ok(mut core) = app.state::<Host>().core.lock() {
|
||||
core.take();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/"}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "NotesAgent Preview",
|
||||
"productName": "OpenNexus",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"identifier": "cc.kronecker.notesagent",
|
||||
"build": {
|
||||
@@ -10,9 +10,9 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
|
||||
"windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"capabilities": ["main"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const pageTitle = computed(() => {
|
||||
logs: t('运行日志', 'Operation Logs'),
|
||||
media: t('音视频转写', 'Media Transcription'),
|
||||
}
|
||||
return titles[name] || 'NotesAgent'
|
||||
return titles[name] || 'OpenNexus'
|
||||
})
|
||||
|
||||
const currentFileName = computed(() => {
|
||||
@@ -63,7 +63,7 @@ function toggleFromTitlebar(event: MouseEvent) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="titlebar-center" data-tauri-drag-region>
|
||||
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
|
||||
<span class="app-name" data-tauri-drag-region>OpenNexus</span>
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
|
||||
|
||||
@@ -258,11 +258,11 @@ onMounted(load)
|
||||
<label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
|
||||
<div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('(兼容)', '(legacy)') }}</button></div>
|
||||
<template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON)', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
|
||||
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 Header(JSON)', '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('普通 Header(JSON)', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"OpenNexus"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
|
||||
<label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
|
||||
<div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
|
||||
</template>
|
||||
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
|
||||
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 OpenNexus 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports OpenNexus, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
|
||||
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -2,17 +2,36 @@
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import { isDesktop } from '@/services/platform/desktop'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
|
||||
import { localeTag, t } from '@/i18n'
|
||||
import FilePicker from '@/components/common/FilePicker.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const maxUploadMiB = isDesktop() ? 64 : 128
|
||||
const submission = createMediaSubmission()
|
||||
const updateExisting = ref(false)
|
||||
const jobs = ref<MediaJob[]>([])
|
||||
const selected = ref<MediaJob | null>(null)
|
||||
const audioSource = ref('')
|
||||
watch(() => selected.value?.attachment_id, async (id, _old, onCleanup) => {
|
||||
let stale = false
|
||||
let objectUrl: string | undefined
|
||||
onCleanup(() => { stale = true; if (objectUrl) URL.revokeObjectURL(objectUrl) })
|
||||
audioSource.value = ''
|
||||
if (!id) return
|
||||
if (!isDesktop()) { audioSource.value = mediaService.audio(id); return }
|
||||
try {
|
||||
const response = await apiClient.get<Response>(`/api/media/attachments/${encodeURIComponent(id)}`)
|
||||
const blob = await response.blob()
|
||||
if (stale) return
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
audioSource.value = objectUrl
|
||||
} catch (e) { if (!stale) error.value = (e as Error).message }
|
||||
})
|
||||
const file = ref<File | null>(null)
|
||||
const reference = ref<File | null>(null)
|
||||
const matchResult = ref('')
|
||||
@@ -62,7 +81,7 @@ async function action(work: () => Promise<void>) {
|
||||
async function submit() {
|
||||
if (!file.value) return
|
||||
await action(async () => {
|
||||
if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.'))
|
||||
if (file.value!.size > maxUploadMiB * 1024 * 1024) throw new Error(t(`文件不能超过 ${maxUploadMiB} MiB。`, `Files cannot exceed ${maxUploadMiB} MiB.`))
|
||||
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
|
||||
let terms = {}
|
||||
if (terminology.value.trim()) {
|
||||
@@ -119,7 +138,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
|
||||
</details>
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
|
||||
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></header>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
|
||||
<form class="panel upload" @submit.prevent="submit">
|
||||
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
|
||||
@@ -141,7 +160,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<article v-if="selected" class="panel transcript">
|
||||
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
|
||||
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
|
||||
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
|
||||
<audio ref="player" controls :src="audioSource || undefined" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
|
||||
<label>{{ t('播放速度', 'Playback speed') }}<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
|
||||
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
|
||||
<p v-if="selected.fallback_reason" class="subtle">{{ t('已回退:', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const locked = ref(true)
|
||||
const busy = ref(false)
|
||||
const password = ref('')
|
||||
const confirmation = ref('')
|
||||
const message = ref('')
|
||||
async function refresh() {
|
||||
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
|
||||
locked.value = state.locked
|
||||
}
|
||||
async function importLegacy() {
|
||||
busy.value = true; message.value = ''
|
||||
try {
|
||||
const count = await hostInvoke<number | null>('credentials_import')
|
||||
if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`)
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : 'MIGRATION_FAILED' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function act(action: 'unlock' | 'lock' | 'change_password') {
|
||||
if (busy.value) return
|
||||
message.value = ''
|
||||
if (action === 'change_password' && password.value !== confirmation.value) {
|
||||
message.value = t('两次口令不一致。', 'The passwords do not match.'); return
|
||||
}
|
||||
busy.value = true
|
||||
const value = password.value
|
||||
password.value = ''; confirmation.value = ''
|
||||
try {
|
||||
await hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value })
|
||||
await refresh()
|
||||
message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : ''
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : 'CREDENTIAL_STORE_FAILED' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onMounted(() => refresh().catch(error => { message.value = String(error) }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel settings-section credential-vault" aria-labelledby="credential-vault-title">
|
||||
<h2 id="credential-vault-title">{{ t('设备凭据保险库', 'Device credential vault') }}</h2>
|
||||
<p>{{ locked ? t('已锁定:使用模型密钥前请解锁。首次解锁将创建本机保险库。', 'Locked: unlock before using provider credentials. The first unlock creates this device’s vault.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p>
|
||||
<p class="subtle">{{ t('口令至少12个字符。遗失口令后需恢复备份或重新配置密钥;笔记仍可使用。', 'Use at least 12 characters. A lost password requires a backup or re-entering credentials; notes remain available.') }}</p>
|
||||
<form @submit.prevent="act(locked ? 'unlock' : 'change_password')">
|
||||
<label>{{ locked ? t('解锁口令', 'Vault password') : t('新口令', 'New password') }}
|
||||
<input v-model="password" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
|
||||
</label>
|
||||
<label v-if="!locked">{{ t('确认新口令', 'Confirm new password') }}
|
||||
<input v-model="confirmation" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
|
||||
</label>
|
||||
<div class="inline-actions">
|
||||
<button class="button-primary" type="submit" :disabled="busy">{{ busy ? t('处理中…', 'Working…') : locked ? t('解锁', 'Unlock') : t('更改口令', 'Change password') }}</button>
|
||||
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="act('lock')">{{ t('立即锁定', 'Lock now') }}</button>
|
||||
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="importLegacy">{{ t('迁移旧凭据…', 'Import legacy credentials…') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credential-vault form { display: grid; gap: 12px; max-width: 480px; }
|
||||
.credential-vault label { display: grid; gap: 6px; }
|
||||
.credential-vault input { color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px; }
|
||||
</style>
|
||||
@@ -12,6 +12,8 @@ import ProviderLogo from './ProviderLogo.vue'
|
||||
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||
import LocalModelSettings from './LocalModelSettings.vue'
|
||||
import UsageCard from './UsageCard.vue'
|
||||
import CredentialVaultSettings from './CredentialVaultSettings.vue'
|
||||
import { isDesktop } from '@/services/platform/desktop'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -85,6 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
|
||||
<section v-if="activeSection === 'general'" class="panel settings-section"><div class="setting-row"><span><strong>{{ t('全局人设', 'Global persona') }}</strong><small>{{ t('统一设置所有 AI 对话和智能体的系统人设与对话示例', 'System persona and examples for all AI chats and agents') }}</small></span><button class="button-secondary" @click="showPersona = true">{{ t('编辑人设与头像', 'Edit persona and avatars') }}</button></div></section>
|
||||
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
|
||||
<CredentialVaultSettings v-if="activeSection === 'general' && isDesktop()" />
|
||||
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
|
||||
|
||||
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label><MarkdownPreferenceSettings /><HeadingStyleSettings /></div>
|
||||
|
||||
@@ -63,7 +63,7 @@ async function openFolderPicker() {
|
||||
<div class="entry-container">
|
||||
<div class="brand-section">
|
||||
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
|
||||
<h1 class="app-title">NotesAgent</h1>
|
||||
<h1 class="app-title">OpenNexus</h1>
|
||||
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ router.beforeEach((to) => {
|
||||
})
|
||||
|
||||
export function updateDocumentTitle(to = router.currentRoute.value) {
|
||||
const baseTitle = 'NotesAgent'
|
||||
const baseTitle = 'OpenNexus'
|
||||
const titles: Record<string, string> = {
|
||||
logs: t('运行日志', 'Operation logs'),
|
||||
media: t('音视频转写', 'Media Transcription'),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ApiError, ErrorResponse } from '@/contracts'
|
||||
import { hostInvoke, isDesktop } from './platform/desktop'
|
||||
|
||||
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? (isDesktop() ? 'http://127.0.0.1:8000' : '')
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
interface DesktopCoreResponse {
|
||||
status: number
|
||||
@@ -82,12 +82,22 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
try {
|
||||
if (isDesktop()) {
|
||||
const parsed = new URL(url)
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
let bodyBase64: string | undefined
|
||||
if (rest.body instanceof Blob) {
|
||||
if (rest.body.size > 64 * 1024 * 1024) throw new ApiErrorClass('CORE_REQUEST_TOO_LARGE', '上传文件超过 64 MiB')
|
||||
const bytes = new Uint8Array(await rest.body.arrayBuffer())
|
||||
const parts: string[] = []
|
||||
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
|
||||
bodyBase64 = btoa(parts.join(''))
|
||||
}
|
||||
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
|
||||
method: rest.method ?? 'GET',
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
|
||||
authorization: token ? `Bearer ${token}` : undefined,
|
||||
bodyBase64,
|
||||
contentType: new Headers(reqHeaders).get('Content-Type'),
|
||||
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
|
||||
})
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
if (response.status === 204) return undefined as T
|
||||
@@ -136,8 +146,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
postBinary<T>(path: string, body: Blob) {
|
||||
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
|
||||
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
|
||||
return request<T>(path, { method: 'POST', body, headers })
|
||||
},
|
||||
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient, resolveApiUrl } from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
import { isDesktop } from './platform/desktop'
|
||||
|
||||
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
|
||||
export interface MediaJob {
|
||||
@@ -24,6 +25,10 @@ export const mediaService = {
|
||||
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
|
||||
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
|
||||
async upload(file: File, idempotencyKey?: string) {
|
||||
if (isDesktop()) return apiClient.postBinary<{ attachment_id: string }>(
|
||||
`/api/media/attachments?filename=${encodeURIComponent(file.name)}`, file,
|
||||
{'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})},
|
||||
)
|
||||
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
const { invoke, channels } = vi.hoisted(() => ({ invoke: vi.fn(), channels: [] as { onmessage: (message: unknown) => void }[] }))
|
||||
vi.mock('./desktop', () => ({ hostInvoke: invoke }))
|
||||
vi.mock('@tauri-apps/api/core', () => ({ Channel: class { onmessage = (_message: unknown) => {}; constructor() { channels.push(this) } } }))
|
||||
import { coreStream } from './coreStream'
|
||||
beforeEach(() => { channels.length = 0; invoke.mockReset(); invoke.mockResolvedValue(undefined) })
|
||||
|
||||
it('preserves UTF-8 byte fragments and cursor without exposing authorization', async () => {
|
||||
const pending = coreStream('/api/events', { method: 'GET', headers: { 'Last-Event-ID': '42', Authorization: 'must-not-forward' } })
|
||||
channels[0]!.onmessage({ kind: 'headers', status: 200 })
|
||||
const response = await pending
|
||||
channels[0]!.onmessage({ kind: 'chunk', data: '5A==' })
|
||||
channels[0]!.onmessage({ kind: 'chunk', data: 'uK0=' })
|
||||
channels[0]!.onmessage({ kind: 'done' })
|
||||
expect(await response.text()).toBe('中')
|
||||
expect(invoke.mock.calls[0]![1]).toMatchObject({ lastEventId: '42' })
|
||||
expect(JSON.stringify(invoke.mock.calls)).not.toContain('must-not-forward')
|
||||
})
|
||||
|
||||
it('cancels a native request even when abort arrives before start acknowledgement', async () => {
|
||||
let acknowledge!: () => void
|
||||
invoke.mockImplementationOnce(() => new Promise<void>(resolve => { acknowledge = resolve }))
|
||||
const abort = new AbortController()
|
||||
const pending = coreStream('/api/events', { signal: abort.signal })
|
||||
abort.abort()
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
acknowledge()
|
||||
await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith('core_stream_cancel', expect.anything()))
|
||||
})
|
||||
|
||||
it('propagates native failure after headers to the response reader', async () => {
|
||||
const pending = coreStream('/api/events', {})
|
||||
channels[0]!.onmessage({ kind: 'headers', status: 200 })
|
||||
const response = await pending
|
||||
channels[0]!.onmessage({ kind: 'error', code: 'CORE_RESPONSE_ERROR' })
|
||||
await expect(response.text()).rejects.toThrow('CORE_RESPONSE_ERROR')
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Channel } from '@tauri-apps/api/core'
|
||||
import { hostInvoke } from './desktop'
|
||||
|
||||
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
|
||||
| { kind: 'done' } | { kind: 'error'; code: string }
|
||||
|
||||
/** Native session credentials stay in Rust; this channel carries response bytes only. */
|
||||
export function coreStream(path: string, init: RequestInit): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
let ended = false
|
||||
let started = false
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>
|
||||
const cancelHost = () => hostInvoke('core_stream_cancel', { requestId }).catch(() => {})
|
||||
const cleanup = () => init.signal?.removeEventListener('abort', abort)
|
||||
const fail = (error: Error) => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
cleanup()
|
||||
controller.error(error)
|
||||
reject(error)
|
||||
if (started) void cancelHost()
|
||||
}
|
||||
const abort = () => fail(new DOMException('Request aborted', 'AbortError'))
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(value) { controller = value },
|
||||
cancel() { ended = true; cleanup(); if (started) void cancelHost() },
|
||||
})
|
||||
const channel = new Channel<Message>()
|
||||
channel.onmessage = message => {
|
||||
if (ended) return
|
||||
if (message.kind === 'headers') resolve(new Response(stream, { status: message.status, headers: { 'Content-Type': 'text/event-stream' } }))
|
||||
if (message.kind === 'chunk') {
|
||||
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
|
||||
controller.enqueue(bytes)
|
||||
// Bound queued data if a consumer stops reading without cancelling.
|
||||
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
|
||||
}
|
||||
if (message.kind === 'error') fail(new Error(message.code))
|
||||
if (message.kind === 'done') { ended = true; cleanup(); controller.close() }
|
||||
}
|
||||
init.signal?.addEventListener('abort', abort, { once: true })
|
||||
if (init.signal?.aborted) { abort(); return }
|
||||
let body: unknown
|
||||
try { body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined }
|
||||
catch { fail(new Error('CORE_BODY_INVALID')); return }
|
||||
void hostInvoke('core_stream', {
|
||||
requestId, path, method: init.method ?? 'GET', body,
|
||||
lastEventId: new Headers(init.headers).get('Last-Event-ID') ?? undefined, channel,
|
||||
}).then(() => { started = true; if (ended) void cancelHost() }).catch(fail)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { resolveApiUrl } from './apiClient'
|
||||
import { isDesktop } from './platform/desktop'
|
||||
import { coreStream } from './platform/coreStream'
|
||||
|
||||
export type SseEventHandler = (
|
||||
event: string,
|
||||
@@ -47,12 +49,13 @@ export class SseClient {
|
||||
headers['Last-Event-ID'] = lastEventId
|
||||
}
|
||||
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
}
|
||||
const resp = isDesktop() ? await coreStream(url, init) : await fetch(resolveApiUrl(url), init)
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`SSE connection failed: ${resp.status}`)
|
||||
|
||||
@@ -21,7 +21,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
|
||||
theme_id: t.theme_id,
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
author: 'NotesAgent 团队',
|
||||
author: 'OpenNexus 团队',
|
||||
description: t.description,
|
||||
is_dark: t.is_dark,
|
||||
builtin: true,
|
||||
@@ -30,7 +30,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
|
||||
theme_id: t.theme_id,
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
author: 'NotesAgent 团队',
|
||||
author: 'OpenNexus 团队',
|
||||
description: t.description,
|
||||
min_app_version: '0.1.0',
|
||||
is_dark: t.is_dark,
|
||||
|
||||
@@ -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()
|
||||
@@ -10,9 +10,18 @@ from .database import Database
|
||||
from .storage import S3Objects
|
||||
|
||||
|
||||
def application():
|
||||
url = os.environ["SYNC_DATABASE_URL"]
|
||||
if not url.startswith("postgresql+psycopg://"):
|
||||
raise RuntimeError("生产入口只支持 PostgreSQL")
|
||||
return create_app(Database(url), S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"]),
|
||||
Path(os.environ["SYNC_STAGING_DIR"]))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=["serve", "migrate", "create-user"])
|
||||
parser.add_argument("command", choices=["serve", "migrate", "create-user", "cleanup-uploads"])
|
||||
parser.add_argument("--workers", type=int, choices=[1, 2], default=2)
|
||||
parser.add_argument("--username")
|
||||
args = parser.parse_args()
|
||||
url = os.environ["SYNC_DATABASE_URL"]
|
||||
@@ -24,8 +33,11 @@ def main():
|
||||
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
|
||||
elif args.command == "serve":
|
||||
import uvicorn
|
||||
app = create_app(db, S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"]), Path(os.environ["SYNC_STAGING_DIR"]))
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080, access_log=False)
|
||||
uvicorn.run("sync_server.__main__:application", factory=True, workers=args.workers,
|
||||
host="0.0.0.0", port=8080, access_log=False)
|
||||
elif args.command == "cleanup-uploads":
|
||||
from .maintenance import cleanup_expired_uploads
|
||||
print(cleanup_expired_uploads(db, Path(os.environ["SYNC_STAGING_DIR"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+118
-14
@@ -4,11 +4,17 @@ import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import os
|
||||
import tempfile
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Header, Query, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from .database import Database, password_hash, row, rows, run
|
||||
from .models import Commit, Login, Refresh, Upload, VaultCreate
|
||||
@@ -26,12 +32,36 @@ def digest(value: str) -> str:
|
||||
def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=time.time):
|
||||
db.migrate()
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
app = FastAPI(title="NotesAgent Sync", version="1.0.0")
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
from .maintenance import cleanup_expired_uploads
|
||||
stopping = asyncio.Event()
|
||||
|
||||
async def maintain():
|
||||
while not stopping.is_set():
|
||||
try:
|
||||
await asyncio.to_thread(cleanup_expired_uploads, db, staging, now=int(clock()))
|
||||
except Exception:
|
||||
logging.getLogger(__name__).error("UPLOAD_MAINTENANCE_FAILED")
|
||||
try:
|
||||
await asyncio.wait_for(stopping.wait(), timeout=60)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
worker = asyncio.create_task(maintain())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stopping.set()
|
||||
await worker
|
||||
|
||||
app = FastAPI(title="OpenNexus Sync", version="1.0.0", lifespan=lifespan)
|
||||
app.state.database = db
|
||||
|
||||
@app.exception_handler(SyncError)
|
||||
async def error(_request, exc):
|
||||
return JSONResponse({"error": {"code": exc.code, "details": exc.details}}, status_code=exc.status)
|
||||
headers = {"Retry-After": "60"} if exc.status == 429 else {}
|
||||
return JSONResponse({"error": {"code": exc.code, "details": exc.details}}, status_code=exc.status, headers=headers)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def invalid(_request, _exc):
|
||||
@@ -66,12 +96,41 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/ready")
|
||||
def ready():
|
||||
def readiness_probe():
|
||||
with db.transaction() as conn:
|
||||
if row(conn, "SELECT version FROM schema_version")["version"] != 1:
|
||||
raise SyncError(503, "SCHEMA_INCOMPATIBLE")
|
||||
return {"status": "ready", "schema": 1}
|
||||
key = "health-probe/" + secrets.token_hex(16)
|
||||
try:
|
||||
with tempfile.TemporaryFile(dir=staging) as local:
|
||||
local.write(b"opennexus-ready")
|
||||
local.flush()
|
||||
os.fsync(local.fileno())
|
||||
local.seek(0)
|
||||
if local.read() != b"opennexus-ready":
|
||||
raise OSError("STAGING_INTEGRITY")
|
||||
objects.put(key, b"opennexus-ready")
|
||||
if objects.get(key) != b"opennexus-ready":
|
||||
raise OSError("STORAGE_INTEGRITY")
|
||||
finally:
|
||||
objects.delete(key)
|
||||
|
||||
ready_lock = asyncio.Lock()
|
||||
ready_cache = {"until": 0.0, "ok": False}
|
||||
|
||||
@app.get("/ready")
|
||||
async def ready():
|
||||
async with ready_lock:
|
||||
if time.monotonic() >= ready_cache["until"]:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.to_thread(readiness_probe), timeout=3)
|
||||
ready_cache["ok"] = True
|
||||
except Exception:
|
||||
ready_cache["ok"] = False
|
||||
ready_cache["until"] = time.monotonic() + 5
|
||||
if not ready_cache["ok"]:
|
||||
raise SyncError(503, "DEPENDENCY_UNAVAILABLE")
|
||||
return {"status": "ready", "schema": 1}
|
||||
|
||||
@app.get("/sync/v1/handshake")
|
||||
def handshake(protocol: int = 1):
|
||||
@@ -171,10 +230,26 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
raise SyncError(404, "UPLOAD_EXPIRED")
|
||||
return upload
|
||||
|
||||
def reconcile_staging(upload):
|
||||
path = staging / upload["id"]
|
||||
try:
|
||||
length = path.stat().st_size
|
||||
if length < upload["offset_bytes"]:
|
||||
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True})
|
||||
if length > upload["offset_bytes"]:
|
||||
with path.open("r+b") as stream:
|
||||
stream.truncate(upload["offset_bytes"])
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError:
|
||||
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True}) from None
|
||||
return path
|
||||
|
||||
@app.get("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
|
||||
def upload_status(vault_id: str, upload_id: str, authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
upload = authorized_upload(conn, vault_id, upload_id, authorization)
|
||||
reconcile_staging(upload)
|
||||
return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]}
|
||||
|
||||
@app.put("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
|
||||
@@ -187,6 +262,7 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
raise SyncError(413, "CHUNK_TOO_LARGE")
|
||||
with db.transaction() as conn:
|
||||
upload = authorized_upload(conn, vault_id, upload_id, authorization)
|
||||
reconcile_staging(upload)
|
||||
if offset != upload["offset_bytes"]:
|
||||
raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]})
|
||||
if offset + len(data) > upload["size"]:
|
||||
@@ -212,15 +288,24 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
@app.post("/sync/v1/vaults/{vault_id}/uploads/{upload_id}/complete")
|
||||
def complete_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
session, _ = vault(conn, vault_id, authorization, lock=True)
|
||||
receipt = row(conn, "SELECT hash FROM upload_receipts WHERE id=:id AND vault_id=:v AND device_id=:d",
|
||||
id=upload_id, v=vault_id, d=session["device_id"])
|
||||
if receipt:
|
||||
return {"complete": True, "content_hash": receipt["hash"]}
|
||||
upload = authorized_upload(conn, vault_id, upload_id, authorization)
|
||||
data = (staging / upload_id).read_bytes()
|
||||
if len(data) != upload["size"] or len(data) != upload["offset_bytes"] or hashlib.sha256(data).hexdigest() != upload["hash"]:
|
||||
path = reconcile_staging(upload)
|
||||
with path.open("rb") as stream:
|
||||
content_hash = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
if upload["size"] != upload["offset_bytes"] or content_hash != upload["hash"]:
|
||||
raise SyncError(422, "OBJECT_INTEGRITY")
|
||||
exists = row(conn, "SELECT hash FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=upload["hash"])
|
||||
if not exists:
|
||||
objects.put(vault_id + "/" + upload["hash"], data)
|
||||
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=len(data), now=int(clock()))
|
||||
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=len(data), v=vault_id)
|
||||
objects.put_file(vault_id + "/" + upload["hash"], path, upload["hash"])
|
||||
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=upload["size"], now=int(clock()))
|
||||
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=upload["size"], v=vault_id)
|
||||
run(conn, "INSERT INTO upload_receipts VALUES (:id,:v,:d,:h,:now)", id=upload_id,
|
||||
v=vault_id, d=session["device_id"], h=upload["hash"], now=int(clock()))
|
||||
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload_id)
|
||||
(staging / upload_id).unlink(missing_ok=True)
|
||||
return {"complete": True, "content_hash": upload["hash"]}
|
||||
@@ -288,9 +373,28 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
obj = row(conn, "SELECT * FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=content_hash)
|
||||
if not obj:
|
||||
raise SyncError(404, "OBJECT_NOT_FOUND")
|
||||
data = objects.get(vault_id + "/" + content_hash)
|
||||
if len(data) != obj["size"] or hashlib.sha256(data).hexdigest() != content_hash:
|
||||
expected_size = obj["size"]
|
||||
# Verify before returning any bytes, without holding a database transaction
|
||||
# or buffering an entire attachment in RAM.
|
||||
temporary = tempfile.NamedTemporaryFile(prefix="download-", dir=staging, delete=False)
|
||||
path = Path(temporary.name)
|
||||
try:
|
||||
size, checksum = 0, hashlib.sha256()
|
||||
with temporary, objects.open(vault_id + "/" + content_hash) as source:
|
||||
while chunk := source.read(1048576):
|
||||
size += len(chunk)
|
||||
if size > expected_size:
|
||||
raise SyncError(503, "STORAGE_INTEGRITY")
|
||||
checksum.update(chunk)
|
||||
temporary.write(chunk)
|
||||
if size != expected_size or checksum.hexdigest() != content_hash:
|
||||
raise SyncError(503, "STORAGE_INTEGRITY")
|
||||
return Response(data, media_type="application/octet-stream", headers={"ETag": '"' + content_hash + '"', "Cache-Control": "private, no-store"})
|
||||
return FileResponse(path, media_type="application/octet-stream",
|
||||
headers={"ETag": '"' + content_hash + '"', "Cache-Control": "private, no-store"},
|
||||
background=BackgroundTask(path.unlink, missing_ok=True))
|
||||
except BaseException:
|
||||
temporary.close()
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return app
|
||||
|
||||
@@ -16,6 +16,7 @@ SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS revisions (vault_id TEXT NOT NULL, sequence BIGINT NOT NULL, file_id TEXT NOT NULL, base_revision BIGINT NOT NULL, path TEXT NOT NULL, path_key TEXT NOT NULL, operation TEXT NOT NULL, hash TEXT, size BIGINT NOT NULL, device_id TEXT NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(vault_id, sequence), UNIQUE(vault_id, operation_id))",
|
||||
"CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))",
|
||||
"CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)",
|
||||
]
|
||||
|
||||
|
||||
@@ -27,11 +28,16 @@ def password_hash(password: str, salt: str | None = None) -> str:
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str):
|
||||
self.engine = create_engine(url)
|
||||
options = {"connect_args": {"connect_timeout": 2, "options": "-c statement_timeout=30000 -c lock_timeout=5000"},
|
||||
"pool_timeout": 2, "pool_pre_ping": True} if url.startswith("postgresql") else {}
|
||||
self.engine = create_engine(url, **options)
|
||||
self.sqlite = self.engine.dialect.name == "sqlite"
|
||||
|
||||
def migrate(self):
|
||||
with self.transaction() as conn:
|
||||
if not self.sqlite:
|
||||
# Serialize factory startup migrations across the supported workers.
|
||||
conn.execute(text("SELECT pg_advisory_xact_lock(1330534488)"))
|
||||
conn.execute(text(SCHEMA[0]))
|
||||
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
||||
if version not in {None, 1}:
|
||||
|
||||
@@ -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}
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class DiskObjects:
|
||||
@@ -27,6 +29,30 @@ class DiskObjects:
|
||||
def get(self, key: str) -> bytes:
|
||||
return (self.root / key).read_bytes()
|
||||
|
||||
def put_file(self, key: str, path: Path, content_hash: str):
|
||||
target = self.root / key
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
with path.open("rb") as source:
|
||||
shutil.copyfileobj(source, stream, 1024 * 1024)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
stream.close()
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
@contextmanager
|
||||
def open(self, key: str):
|
||||
with (self.root / key).open("rb") as stream:
|
||||
yield stream
|
||||
|
||||
def delete(self, key: str):
|
||||
(self.root / key).unlink(missing_ok=True)
|
||||
|
||||
@@ -34,7 +60,10 @@ class DiskObjects:
|
||||
class S3Objects:
|
||||
def __init__(self, endpoint: str, bucket: str):
|
||||
import boto3
|
||||
self.client = boto3.client("s3", endpoint_url=endpoint)
|
||||
from botocore.config import Config
|
||||
self.client = boto3.client("s3", endpoint_url=endpoint,
|
||||
config=Config(connect_timeout=2, read_timeout=2,
|
||||
retries={"max_attempts": 0}))
|
||||
self.bucket = bucket
|
||||
|
||||
def put(self, key: str, data: bytes):
|
||||
@@ -46,5 +75,18 @@ class S3Objects:
|
||||
with response["Body"] as stream:
|
||||
return stream.read()
|
||||
|
||||
def put_file(self, key: str, path: Path, content_hash: str):
|
||||
from boto3.s3.transfer import TransferConfig
|
||||
with path.open("rb") as stream:
|
||||
self.client.upload_fileobj(stream, self.bucket, key,
|
||||
ExtraArgs={"Metadata": {"sha256": content_hash}},
|
||||
Config=TransferConfig(use_threads=False, max_concurrency=1))
|
||||
|
||||
@contextmanager
|
||||
def open(self, key: str):
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=key)
|
||||
with response["Body"] as stream:
|
||||
yield stream
|
||||
|
||||
def delete(self, key: str):
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Fault vectors for the production IO paths; database still uses an isolated fixture."""
|
||||
import hashlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sync_server.app import create_app
|
||||
from sync_server.database import Database, row
|
||||
from sync_server.maintenance import cleanup_expired_uploads
|
||||
from sync_server.storage import DiskObjects
|
||||
from test_protocol import setup, upload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
db = Database("sqlite:///" + str(tmp_path / "sync.db"))
|
||||
store = DiskObjects(tmp_path / "objects")
|
||||
staging = tmp_path / "staging"
|
||||
now = [1000]
|
||||
app = create_app(db, store, staging, clock=lambda: now[0])
|
||||
db.add_user("alice", "controlled-fixture-password")
|
||||
with TestClient(app) as client:
|
||||
yield client, db, store, staging, now
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def test_offset_reconciliation_never_acknowledges_missing_disk_bytes(env):
|
||||
client, _, _, staging, _ = env
|
||||
auth, _, base = setup(client)
|
||||
info = client.post(base + "/uploads", headers=auth,
|
||||
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3}).json()
|
||||
path = base + "/uploads/" + info["upload_id"]
|
||||
assert client.put(path + "?offset=0", headers=auth, content=b"a").status_code == 200
|
||||
local = staging / info["upload_id"]
|
||||
local.write_bytes(b"abc")
|
||||
assert client.get(path, headers=auth).json()["offset"] == 1
|
||||
assert local.read_bytes() == b"a"
|
||||
local.write_bytes(b"")
|
||||
assert client.get(path, headers=auth).json()["error"]["code"] == "UPLOAD_DAMAGED"
|
||||
assert client.put(path + "?offset=1", headers=auth, content=b"bc").status_code == 409
|
||||
|
||||
|
||||
def test_complete_retry_has_durable_receipt_and_charges_once(env):
|
||||
client, db, _, staging, now = env
|
||||
auth, _, base = setup(client)
|
||||
info = client.post(base + "/uploads", headers=auth,
|
||||
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3}).json()
|
||||
path = base + "/uploads/" + info["upload_id"]
|
||||
assert client.put(path + "?offset=0", headers=auth, content=b"abc").status_code == 200
|
||||
first = client.post(path + "/complete", headers=auth)
|
||||
assert first.status_code == 200
|
||||
assert not (staging / info["upload_id"]).exists()
|
||||
for _ in range(100):
|
||||
retry = client.post(path + "/complete", headers=auth)
|
||||
assert retry.status_code == 200
|
||||
assert retry.json() == first.json()
|
||||
assert client.post(path + "/complete").status_code == 401
|
||||
with db.transaction() as conn:
|
||||
assert row(conn, "SELECT used FROM vaults WHERE id=:id", id=base.rsplit("/", 1)[1])["used"] == 3
|
||||
assert row(conn, "SELECT COUNT(*) AS n FROM upload_receipts")["n"] == 1
|
||||
|
||||
|
||||
def test_download_is_verified_before_response_and_temp_files_are_removed(env):
|
||||
client, _, store, staging, _ = env
|
||||
auth, _, base = setup(client)
|
||||
sha = upload(client, base, auth)
|
||||
# This path must use streaming open(), never the full-object get().
|
||||
store.get = lambda key: pytest.fail("full-object read")
|
||||
response = client.get(base + "/objects/" + sha, headers=auth)
|
||||
assert response.content == b"controlled note"
|
||||
assert list(staging.glob("download-*")) == []
|
||||
(store.root / base.rsplit("/", 1)[1] / sha).write_bytes(b"corruption")
|
||||
response = client.get(base + "/objects/" + sha, headers=auth)
|
||||
assert response.status_code == 503
|
||||
assert b"corruption" not in response.content
|
||||
assert list(staging.glob("download-*")) == []
|
||||
|
||||
|
||||
def test_cleanup_only_expired_staging_preserves_committed_objects(env):
|
||||
client, db, store, staging, now = env
|
||||
auth, _, base = setup(client)
|
||||
sha = upload(client, base, auth)
|
||||
info = client.post(base + "/uploads", headers=auth,
|
||||
json={"content_hash": hashlib.sha256(b"pending").hexdigest(), "size": 7}).json()
|
||||
assert cleanup_expired_uploads(db, staging, now=1001)["expired_uploads_removed"] == 0
|
||||
now[0] += 3601
|
||||
assert cleanup_expired_uploads(db, staging, now=now[0])["expired_uploads_removed"] == 1
|
||||
assert not (staging / info["upload_id"]).exists()
|
||||
assert cleanup_expired_uploads(db, staging, now=now[0])["expired_uploads_removed"] == 0
|
||||
assert store.get(base.rsplit("/",1)[1] + "/" + sha) == b"controlled note"
|
||||
with db.transaction() as conn:
|
||||
assert row(conn,"SELECT used FROM vaults")["used"] == len(b"controlled note")
|
||||
|
||||
|
||||
def test_readiness_fails_closed_when_object_storage_unavailable(env):
|
||||
client, _, store, _, _ = env
|
||||
def unavailable(*args):
|
||||
raise OSError("simulated outage")
|
||||
store.put = unavailable
|
||||
assert client.get("/ready").status_code == 503
|
||||
assert client.get("/health").status_code == 200
|
||||
Reference in New Issue
Block a user