feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user