Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97808b70a7 | ||
|
|
910a16a998 |
@@ -0,0 +1,3 @@
|
|||||||
|
"""Canonical AIOS V2R1 runtime package."""
|
||||||
|
|
||||||
|
__version__ = "2.1.0-rc.1"
|
||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .cell_manager import CellManager
|
from .cell_manager import CellManager
|
||||||
|
from .ingress_service import serve
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -29,8 +30,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def build_serve_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="aios-cell serve")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=18792)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
values = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
service_mode = (
|
||||||
|
bool(values)
|
||||||
|
and values[0] == "serve"
|
||||||
|
and (len(values) == 1 or values[1].startswith("-"))
|
||||||
|
)
|
||||||
|
if service_mode:
|
||||||
|
serve_args = build_serve_parser().parse_args(values[1:])
|
||||||
|
serve(serve_args.host, serve_args.port)
|
||||||
|
return 0
|
||||||
|
args = build_parser().parse_args(values)
|
||||||
manager = CellManager(args.registry)
|
manager = CellManager(args.registry)
|
||||||
if args.command == "create":
|
if args.command == "create":
|
||||||
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
"""Fail-closed loopback HTTP shell for V2R1 ingress processors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
MAX_REQUEST_BYTES = 1024 * 1024
|
||||||
|
MAX_RESPONSE_BYTES = 4096
|
||||||
|
REQUEST_IO_TIMEOUT_SECONDS = 0.5
|
||||||
|
REQUEST_NETWORK_DEADLINE_SECONDS = 1.0
|
||||||
|
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
||||||
|
HEADER_NAME_BYTES = frozenset(
|
||||||
|
b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
)
|
||||||
|
INTERNAL_ERROR_BODY = b'{"status":"INTERNAL_ERROR"}'
|
||||||
|
MUTATION_ROUTES = frozenset(
|
||||||
|
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
||||||
|
)
|
||||||
|
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
class RequestRejected(Exception):
|
||||||
|
def __init__(self, status: int, payload_status: str) -> None:
|
||||||
|
super().__init__(payload_status)
|
||||||
|
self.status = status
|
||||||
|
self.payload_status = payload_status
|
||||||
|
|
||||||
|
|
||||||
|
class IngressHTTPServer(ThreadingHTTPServer):
|
||||||
|
"""Threaded loopback server with an optional externally supplied processor."""
|
||||||
|
|
||||||
|
daemon_threads = False
|
||||||
|
block_on_close = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
address: tuple[str, int],
|
||||||
|
handler: type[BaseHTTPRequestHandler],
|
||||||
|
processor: Processor | None,
|
||||||
|
) -> None:
|
||||||
|
self._closing = threading.Event()
|
||||||
|
self._lifecycle_lock = threading.RLock()
|
||||||
|
self._connections_lock = threading.Lock()
|
||||||
|
self._connections: set[socket.socket] = set()
|
||||||
|
self._server_close_lock = threading.Lock()
|
||||||
|
self.processor = processor
|
||||||
|
super().__init__(address, handler)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool:
|
||||||
|
return self._closing.is_set()
|
||||||
|
|
||||||
|
def get_request(self) -> tuple[socket.socket, tuple[str, int]]:
|
||||||
|
"""Register each accepted socket before handler scheduling can begin."""
|
||||||
|
connection, client_address = super().get_request()
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._closing.is_set():
|
||||||
|
reject = True
|
||||||
|
else:
|
||||||
|
with self._connections_lock:
|
||||||
|
self._connections.add(connection)
|
||||||
|
reject = False
|
||||||
|
if reject:
|
||||||
|
self._shutdown_and_close_connection(connection)
|
||||||
|
raise OSError("server is closing")
|
||||||
|
return connection, client_address
|
||||||
|
|
||||||
|
def unregister_connection(self, connection: socket.socket) -> None:
|
||||||
|
with self._connections_lock:
|
||||||
|
self._connections.discard(connection)
|
||||||
|
|
||||||
|
def _shutdown_and_close_connection(self, connection: socket.socket) -> None:
|
||||||
|
try:
|
||||||
|
connection.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
connection.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.unregister_connection(connection)
|
||||||
|
|
||||||
|
def _close_accepted_connections(self) -> None:
|
||||||
|
with self._connections_lock:
|
||||||
|
connections = tuple(self._connections)
|
||||||
|
for connection in connections:
|
||||||
|
self._shutdown_and_close_connection(connection)
|
||||||
|
|
||||||
|
def process_request(
|
||||||
|
self,
|
||||||
|
request: socket.socket,
|
||||||
|
client_address: tuple[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""Linearize request admission with the transition to closing."""
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._closing.is_set():
|
||||||
|
reject = True
|
||||||
|
else:
|
||||||
|
reject = False
|
||||||
|
try:
|
||||||
|
super().process_request(request, client_address)
|
||||||
|
except Exception:
|
||||||
|
self._shutdown_and_close_connection(request)
|
||||||
|
raise
|
||||||
|
if reject:
|
||||||
|
self._shutdown_and_close_connection(request)
|
||||||
|
|
||||||
|
def shutdown_request(self, request: socket.socket) -> None:
|
||||||
|
try:
|
||||||
|
request.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
super().shutdown_request(request)
|
||||||
|
finally:
|
||||||
|
self.unregister_connection(request)
|
||||||
|
|
||||||
|
def call_processor(self, path: str, body: bytes) -> tuple[bool, Any]:
|
||||||
|
"""Start processing atomically with respect to server shutdown."""
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._closing.is_set() or self.processor is None:
|
||||||
|
return False, None
|
||||||
|
return True, self.processor(path, body)
|
||||||
|
|
||||||
|
def _begin_closing(self) -> None:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
self._closing.set()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self._begin_closing()
|
||||||
|
self._close_accepted_connections()
|
||||||
|
super().shutdown()
|
||||||
|
|
||||||
|
def server_close(self) -> None:
|
||||||
|
"""Interrupt accepted sockets before the base class joins handlers."""
|
||||||
|
with self._server_close_lock:
|
||||||
|
self._begin_closing()
|
||||||
|
self._close_accepted_connections()
|
||||||
|
super().server_close()
|
||||||
|
|
||||||
|
def handle_error(
|
||||||
|
self,
|
||||||
|
request: socket.socket,
|
||||||
|
client_address: tuple[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""Close unexpected handler failures without printing request data."""
|
||||||
|
try:
|
||||||
|
request.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _ValidatingHeaderReader:
|
||||||
|
"""Validate raw header lines before the standard library normalizes them."""
|
||||||
|
|
||||||
|
def __init__(self, handler: "IngressRequestHandler", reader: Any) -> None:
|
||||||
|
self._handler = handler
|
||||||
|
self._reader = reader
|
||||||
|
|
||||||
|
def readline(self, limit: int = -1) -> bytes:
|
||||||
|
try:
|
||||||
|
line = self._reader.readline(limit)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
||||||
|
self._handler._ensure_network_read_active()
|
||||||
|
self._validate(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate(line: bytes) -> None:
|
||||||
|
if line == b"\r\n":
|
||||||
|
return
|
||||||
|
if not line or not line.endswith(b"\r\n"):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
raw = line[:-2]
|
||||||
|
if not raw or raw[:1] in {b" ", b"\t"}:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
separator = raw.find(b":")
|
||||||
|
if separator <= 0:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
name = raw[:separator]
|
||||||
|
value = raw[separator + 1 :]
|
||||||
|
if any(byte not in HEADER_NAME_BYTES for byte in name):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in value):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
if name.lower() == b"content-length":
|
||||||
|
numeric = value[1:] if value.startswith(b" ") else value
|
||||||
|
if not numeric or any(byte < 0x30 or byte > 0x39 for byte in numeric):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
|
||||||
|
|
||||||
|
class IngressRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
"""Bounded JSON transport; application processing is injected later."""
|
||||||
|
|
||||||
|
server: IngressHTTPServer
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
super().setup()
|
||||||
|
self.connection.settimeout(REQUEST_IO_TIMEOUT_SECONDS)
|
||||||
|
self._network_read_lock = threading.Lock()
|
||||||
|
self._network_read_expired = threading.Event()
|
||||||
|
self._network_read_deadline: float | None = None
|
||||||
|
self._network_read_timer: threading.Timer | None = None
|
||||||
|
self._network_read_active = False
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
self._complete_network_read()
|
||||||
|
try:
|
||||||
|
super().finish()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _start_network_read(self) -> None:
|
||||||
|
self._complete_network_read()
|
||||||
|
timer = threading.Timer(
|
||||||
|
REQUEST_NETWORK_DEADLINE_SECONDS,
|
||||||
|
self._expire_network_read,
|
||||||
|
)
|
||||||
|
timer.name = f"aios-ingress-read-deadline-{id(self)}"
|
||||||
|
timer.daemon = True
|
||||||
|
with self._network_read_lock:
|
||||||
|
self._network_read_expired.clear()
|
||||||
|
self._network_read_deadline = (
|
||||||
|
time.monotonic() + REQUEST_NETWORK_DEADLINE_SECONDS
|
||||||
|
)
|
||||||
|
self._network_read_active = True
|
||||||
|
self._network_read_timer = timer
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
def _expire_network_read(self) -> None:
|
||||||
|
with self._network_read_lock:
|
||||||
|
if not self._network_read_active:
|
||||||
|
return
|
||||||
|
self._network_read_expired.set()
|
||||||
|
self.close_connection = True
|
||||||
|
try:
|
||||||
|
self.connection.shutdown(socket.SHUT_RD)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _complete_network_read(self) -> None:
|
||||||
|
timer: threading.Timer | None
|
||||||
|
with getattr(self, "_network_read_lock", threading.Lock()):
|
||||||
|
self._network_read_active = False
|
||||||
|
self._network_read_deadline = None
|
||||||
|
timer = self._network_read_timer
|
||||||
|
self._network_read_timer = None
|
||||||
|
if timer is not None:
|
||||||
|
timer.cancel()
|
||||||
|
if timer is not threading.current_thread():
|
||||||
|
timer.join(timeout=REQUEST_IO_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
def _ensure_network_read_active(self) -> None:
|
||||||
|
if self.server.is_closing:
|
||||||
|
raise RequestRejected(503, "SERVICE_UNAVAILABLE")
|
||||||
|
deadline = self._network_read_deadline
|
||||||
|
if self._network_read_expired.is_set() or (
|
||||||
|
deadline is not None and time.monotonic() >= deadline
|
||||||
|
):
|
||||||
|
self._network_read_expired.set()
|
||||||
|
raise RequestRejected(408, "REQUEST_TIMEOUT")
|
||||||
|
|
||||||
|
def parse_request(self) -> bool:
|
||||||
|
original_reader = self.rfile
|
||||||
|
self.rfile = _ValidatingHeaderReader(self, original_reader) # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
return super().parse_request()
|
||||||
|
finally:
|
||||||
|
self.rfile = original_reader
|
||||||
|
|
||||||
|
def handle_one_request(self) -> None:
|
||||||
|
self._start_network_read()
|
||||||
|
try:
|
||||||
|
super().handle_one_request()
|
||||||
|
except RequestRejected as rejection:
|
||||||
|
self.close_connection = True
|
||||||
|
self._send_safely(rejection.status, {"status": rejection.payload_status})
|
||||||
|
except TimeoutError:
|
||||||
|
self.close_connection = True
|
||||||
|
self._send_safely(408, {"status": "REQUEST_TIMEOUT"})
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
self.close_connection = True
|
||||||
|
except Exception:
|
||||||
|
self.close_connection = True
|
||||||
|
self._send_safely(500, {"status": "INTERNAL_ERROR"})
|
||||||
|
finally:
|
||||||
|
self._complete_network_read()
|
||||||
|
|
||||||
|
def send_error(
|
||||||
|
self,
|
||||||
|
code: int,
|
||||||
|
message: str | None = None,
|
||||||
|
explain: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
|
||||||
|
self.close_connection = True
|
||||||
|
if code == 501:
|
||||||
|
self._method_not_allowed()
|
||||||
|
return
|
||||||
|
self._send(int(code), {"status": "HTTP_ERROR"})
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _send_safely(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
self._send(status, payload)
|
||||||
|
except OSError:
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
|
def _path(self) -> str:
|
||||||
|
target = self.path
|
||||||
|
if not target or any(
|
||||||
|
ord(character) < 0x20 or ord(character) == 0x7F for character in target
|
||||||
|
):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
for index, character in enumerate(target):
|
||||||
|
if character == "%" and (
|
||||||
|
index + 2 >= len(target)
|
||||||
|
or any(value not in HEX_DIGITS for value in target[index + 1 : index + 3])
|
||||||
|
):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
try:
|
||||||
|
parts = urlsplit(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||||
|
if parts.fragment:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
if target.startswith("/"):
|
||||||
|
if parts.scheme or parts.netloc:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
elif parts.scheme in {"http", "https"} and parts.netloc:
|
||||||
|
if parts.hostname is None:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
try:
|
||||||
|
_ = parts.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||||
|
else:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
return parts.path or "/"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encoded_response(
|
||||||
|
status: object,
|
||||||
|
payload: object,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
if (
|
||||||
|
type(status) is not int
|
||||||
|
or not 200 <= status <= 599
|
||||||
|
or status in {204, 205, 304}
|
||||||
|
or not isinstance(payload, Mapping)
|
||||||
|
):
|
||||||
|
return 500, INTERNAL_ERROR_BODY
|
||||||
|
try:
|
||||||
|
body = json.dumps(
|
||||||
|
dict(payload),
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return 500, INTERNAL_ERROR_BODY
|
||||||
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
return 500, INTERNAL_ERROR_BODY
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
self._complete_network_read()
|
||||||
|
status, body = self._encoded_response(status, payload)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
if getattr(self, "command", None) != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _method_not_allowed(self) -> None:
|
||||||
|
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
||||||
|
|
||||||
|
def _reject(self, status: int, payload_status: str) -> None:
|
||||||
|
self.close_connection = True
|
||||||
|
self._send(status, {"status": payload_status})
|
||||||
|
|
||||||
|
def _content_length(self) -> int:
|
||||||
|
if self.headers.get_all("Transfer-Encoding", []):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
values = self.headers.get_all("Content-Length", [])
|
||||||
|
if len(values) > 1:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
if not values:
|
||||||
|
return 0
|
||||||
|
raw_length = values[0]
|
||||||
|
if (
|
||||||
|
not raw_length
|
||||||
|
or not raw_length.isascii()
|
||||||
|
or not raw_length.isdigit()
|
||||||
|
):
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
try:
|
||||||
|
length = int(raw_length)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||||
|
if length > MAX_REQUEST_BYTES:
|
||||||
|
raise RequestRejected(413, "REQUEST_TOO_LARGE")
|
||||||
|
return length
|
||||||
|
|
||||||
|
def _read_exact_body(self, length: int) -> bytes:
|
||||||
|
try:
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
||||||
|
self._ensure_network_read_active()
|
||||||
|
if len(body) != length:
|
||||||
|
raise RequestRejected(400, "BAD_REQUEST")
|
||||||
|
return body
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = self._path()
|
||||||
|
if path == "/health/live":
|
||||||
|
self._send(200, {"status": "LIVE"})
|
||||||
|
elif path == "/health/ready":
|
||||||
|
if self.server.processor is None:
|
||||||
|
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||||
|
else:
|
||||||
|
self._send(200, {"status": "READY"})
|
||||||
|
elif path in MUTATION_ROUTES:
|
||||||
|
self._method_not_allowed()
|
||||||
|
else:
|
||||||
|
self._send(404, {"status": "NOT_FOUND"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = self._path()
|
||||||
|
if path not in MUTATION_ROUTES:
|
||||||
|
if path in {"/health/live", "/health/ready"}:
|
||||||
|
self._method_not_allowed()
|
||||||
|
else:
|
||||||
|
self._send(404, {"status": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = self._content_length()
|
||||||
|
except RequestRejected as rejection:
|
||||||
|
self._reject(rejection.status, rejection.payload_status)
|
||||||
|
return
|
||||||
|
processor = self.server.processor
|
||||||
|
if processor is None:
|
||||||
|
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||||
|
return
|
||||||
|
if self.server.is_closing:
|
||||||
|
self._reject(503, "SERVICE_UNAVAILABLE")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self._read_exact_body(length)
|
||||||
|
except RequestRejected as rejection:
|
||||||
|
self._reject(rejection.status, rejection.payload_status)
|
||||||
|
return
|
||||||
|
self._complete_network_read()
|
||||||
|
try:
|
||||||
|
started, result = self.server.call_processor(path, body)
|
||||||
|
if not started:
|
||||||
|
self._reject(503, "SERVICE_UNAVAILABLE")
|
||||||
|
return
|
||||||
|
status, payload = result
|
||||||
|
except Exception:
|
||||||
|
self._send(500, {"status": "INTERNAL_ERROR"})
|
||||||
|
return
|
||||||
|
self._send(status, payload)
|
||||||
|
|
||||||
|
do_DELETE = _method_not_allowed
|
||||||
|
do_HEAD = _method_not_allowed
|
||||||
|
do_OPTIONS = _method_not_allowed
|
||||||
|
do_PATCH = _method_not_allowed
|
||||||
|
do_PUT = _method_not_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
port: int = 18792,
|
||||||
|
*,
|
||||||
|
processor: Processor | None = None,
|
||||||
|
) -> IngressHTTPServer:
|
||||||
|
"""Build a loopback-only HTTP server without starting it."""
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(host)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("host must be a loopback IP address") from exc
|
||||||
|
if address.version != 4 or not address.is_loopback:
|
||||||
|
raise ValueError("host must be an IPv4 loopback address")
|
||||||
|
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
|
||||||
|
"""Run the unconfigured loopback shell until interrupted."""
|
||||||
|
server = build_server(host, port)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "aios-v2r1-runtime"
|
||||||
|
version = "2.1.0rc1"
|
||||||
|
description = "AIOS V2R1 canonical runtime package"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
aios-cell = "aios_v2r1.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "app"}
|
||||||
|
packages = ["aios_v2r1"]
|
||||||
+35
-10
@@ -1,31 +1,48 @@
|
|||||||
import {
|
import {
|
||||||
classifyRun,
|
classifyRun,
|
||||||
decideToolCall,
|
decideToolCall,
|
||||||
|
resolveSenderId,
|
||||||
runKey,
|
runKey,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
|
|
||||||
const activeRuns = new Map();
|
|
||||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
function prune(now = Date.now()) {
|
function prune(activeRuns, now = Date.now()) {
|
||||||
for (const [key, value] of activeRuns.entries()) {
|
for (const [key, value] of activeRuns.entries()) {
|
||||||
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function approvalTimeout(config) {
|
||||||
|
const value = config.approvalTimeoutMs;
|
||||||
|
return Number.isInteger(value) && value >= 1000 && value <= 300000
|
||||||
|
? value
|
||||||
|
: 60000;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
id: "aios-tenant-boundary",
|
id: "aios-tenant-boundary",
|
||||||
name: "AIOS Tenant Boundary",
|
name: "AIOS Tenant Boundary",
|
||||||
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
||||||
|
|
||||||
register(api) {
|
register(api) {
|
||||||
|
const activeRuns = new Map();
|
||||||
|
|
||||||
api.on(
|
api.on(
|
||||||
"before_agent_run",
|
"before_agent_run",
|
||||||
async (event, ctx) => {
|
async (event, ctx) => {
|
||||||
prune();
|
prune(activeRuns);
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||||
const trust = classifyRun(ctx, config);
|
const trust = classifyRun(ctx, config);
|
||||||
const key = runKey(event, ctx);
|
const key = runKey(event, ctx);
|
||||||
|
const existing = key ? activeRuns.get(key) : undefined;
|
||||||
|
if (existing && existing.senderId !== trust.senderId) {
|
||||||
|
return {
|
||||||
|
outcome: "block",
|
||||||
|
reason: "run_scope_sender_conflict",
|
||||||
|
message: "This run scope is already bound to a different authenticated sender.",
|
||||||
|
};
|
||||||
|
}
|
||||||
if (key) {
|
if (key) {
|
||||||
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -49,10 +66,14 @@ export default {
|
|||||||
api.on(
|
api.on(
|
||||||
"before_tool_call",
|
"before_tool_call",
|
||||||
async (event, ctx) => {
|
async (event, ctx) => {
|
||||||
prune();
|
prune(activeRuns);
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||||
const key = runKey(event, ctx);
|
const key = runKey(event, ctx);
|
||||||
const trust = key ? activeRuns.get(key) : undefined;
|
let trust = key ? activeRuns.get(key) : undefined;
|
||||||
|
const senderId = resolveSenderId(ctx);
|
||||||
|
if (trust && senderId && trust.senderId !== senderId) {
|
||||||
|
trust = { trusted: false, reason: "run_scope_sender_conflict" };
|
||||||
|
}
|
||||||
const decision = decideToolCall(event.toolName, trust, config);
|
const decision = decideToolCall(event.toolName, trust, config);
|
||||||
|
|
||||||
if (decision.action === "block") {
|
if (decision.action === "block") {
|
||||||
@@ -67,7 +88,7 @@ export default {
|
|||||||
title: `Approve ${String(event.toolName)}`,
|
title: `Approve ${String(event.toolName)}`,
|
||||||
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
timeoutMs: approvalTimeout(config),
|
||||||
allowedDecisions: ["allow-once", "deny"],
|
allowedDecisions: ["allow-once", "deny"],
|
||||||
pluginId: "aios-tenant-boundary",
|
pluginId: "aios-tenant-boundary",
|
||||||
},
|
},
|
||||||
@@ -78,9 +99,13 @@ export default {
|
|||||||
{ priority: 100 },
|
{ priority: 100 },
|
||||||
);
|
);
|
||||||
|
|
||||||
api.on("agent_end", async (event, ctx) => {
|
api.on(
|
||||||
const key = runKey(event, ctx);
|
"agent_end",
|
||||||
if (key) activeRuns.delete(key);
|
async (event, ctx) => {
|
||||||
});
|
const key = runKey(event, ctx);
|
||||||
|
if (key) activeRuns.delete(key);
|
||||||
|
},
|
||||||
|
{ priority: 100 },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-2
@@ -66,6 +66,26 @@ export function decideToolCall(toolName, runTrust, config = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runKey(event = {}, ctx = {}) {
|
export function runKey(event = {}, ctx = {}) {
|
||||||
const key = event.runId || ctx.runId;
|
const normalize = (value) => typeof value === "string" && value.trim()
|
||||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
? value.trim()
|
||||||
|
: undefined;
|
||||||
|
const sameField = (eventValue, contextValue) => {
|
||||||
|
const fromEvent = normalize(eventValue);
|
||||||
|
const fromContext = normalize(contextValue);
|
||||||
|
return {
|
||||||
|
valid: !(fromEvent && fromContext && fromEvent !== fromContext),
|
||||||
|
value: fromEvent || fromContext,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const runId = sameField(event.runId, ctx.runId);
|
||||||
|
const agentId = sameField(event.agentId, ctx.agentId);
|
||||||
|
const sessionId = sameField(event.sessionId, ctx.sessionId);
|
||||||
|
const sessionKey = sameField(event.sessionKey, ctx.sessionKey);
|
||||||
|
if (!runId.valid || !agentId.valid || !sessionId.valid || !sessionKey.valid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const selectedSession = sessionId.value || sessionKey.value;
|
||||||
|
return runId.value && agentId.value && selectedSession
|
||||||
|
? JSON.stringify([runId.value, agentId.value, selectedSession])
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "aios-tenant-boundary",
|
"id": "aios-tenant-boundary",
|
||||||
"name": "AIOS Tenant Boundary",
|
"name": "AIOS Tenant Boundary",
|
||||||
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
|
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
|
||||||
"version": "2.1.0-rc1",
|
"version": "2.1.0-rc.1",
|
||||||
"entry": "dist/index.js",
|
"entry": "dist/index.js",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -12,7 +12,14 @@
|
|||||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true }
|
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||||
|
"blockUnknownAgentRuns": { "type": "boolean", "default": false },
|
||||||
|
"approvalTimeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 300000,
|
||||||
|
"default": 60000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import plugin from "../dist/index.js";
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function registrations(config = {}) {
|
||||||
|
const seen = [];
|
||||||
|
plugin.register({
|
||||||
|
pluginConfig: config,
|
||||||
|
on(name, handler, options) {
|
||||||
|
seen.push({ name, handler, options });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("package, manifest, and executable test command are release compatible", () => {
|
||||||
|
assert.equal(packageJson.version, "2.1.0-rc.1");
|
||||||
|
assert.equal(manifest.version, "2.1.0-rc.1");
|
||||||
|
assert.equal(packageJson.scripts.test, "node --test test/*.test.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schema declares every runtime-read property and exact B1 bounds", () => {
|
||||||
|
const properties = manifest.configSchema.properties;
|
||||||
|
const runtimeKeys = [
|
||||||
|
"approvalTimeoutMs",
|
||||||
|
"blockUnknownAgentRuns",
|
||||||
|
"blockedSenderIds",
|
||||||
|
"readOnlyTools",
|
||||||
|
"requireApprovalForHumanSideEffects",
|
||||||
|
"requireExplicitTrustedSenders",
|
||||||
|
"trustedSenderIds",
|
||||||
|
];
|
||||||
|
assert.deepEqual(Object.keys(properties).sort(), runtimeKeys);
|
||||||
|
assert.deepEqual(properties.blockUnknownAgentRuns, {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(properties.approvalTimeoutMs, {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1000,
|
||||||
|
maximum: 300000,
|
||||||
|
default: 60000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all three hooks register exactly once at priority 100", () => {
|
||||||
|
const seen = registrations();
|
||||||
|
assert.deepEqual(
|
||||||
|
seen.map(({ name }) => name).sort(),
|
||||||
|
["agent_end", "before_agent_run", "before_tool_call"],
|
||||||
|
);
|
||||||
|
for (const registration of seen) {
|
||||||
|
assert.deepEqual(registration.options, { priority: 100 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
|
||||||
|
const [mode, inspectPath] = process.argv.slice(2);
|
||||||
|
assert.ok(["default", "opt-in"].includes(mode), `unsupported mode: ${mode}`);
|
||||||
|
assert.ok(inspectPath, "inspect JSON path is required");
|
||||||
|
|
||||||
|
const data = JSON.parse(await readFile(inspectPath, "utf8"));
|
||||||
|
const plugin = data.plugin;
|
||||||
|
assert.equal(plugin.id, "aios-tenant-boundary");
|
||||||
|
assert.equal(plugin.version, "2.1.0-rc.1");
|
||||||
|
assert.equal(plugin.status, "loaded");
|
||||||
|
assert.equal(plugin.activated, true);
|
||||||
|
assert.equal(await realpath(plugin.rootDir), await realpath(new URL("..", import.meta.url)));
|
||||||
|
assert.equal(await realpath(plugin.source), await realpath(new URL("../dist/index.js", import.meta.url)));
|
||||||
|
assert.equal(data.install.source, "path");
|
||||||
|
assert.equal(await realpath(data.install.installPath), await realpath(plugin.rootDir));
|
||||||
|
|
||||||
|
const hooks = data.typedHooks
|
||||||
|
.map(({ name, priority }) => ({ name, priority }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const expectedNames = mode === "default"
|
||||||
|
? ["before_tool_call"]
|
||||||
|
: ["agent_end", "before_agent_run", "before_tool_call"];
|
||||||
|
assert.deepEqual(hooks.map(({ name }) => name), expectedNames);
|
||||||
|
assert.ok(hooks.every(({ priority }) => priority === 100));
|
||||||
|
|
||||||
|
const diagnostics = data.diagnostics ?? [];
|
||||||
|
assert.equal(
|
||||||
|
diagnostics.filter(({ level, severity }) =>
|
||||||
|
["error", "fatal"].includes(String(level ?? severity).toLowerCase())).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (mode === "default") {
|
||||||
|
assert.deepEqual(
|
||||||
|
diagnostics.map(({ level, message }) => ({ level, message })).sort((a, b) => a.message.localeCompare(b.message)),
|
||||||
|
["agent_end", "before_agent_run"].map((hook) => ({
|
||||||
|
level: "warn",
|
||||||
|
message: `typed hook "${hook}" blocked because non-bundled plugins must set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`,
|
||||||
|
})).sort((a, b) => a.message.localeCompare(b.message)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(diagnostics, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = plugin.configJsonSchema?.properties ?? {};
|
||||||
|
for (const key of ["approvalTimeoutMs", "blockUnknownAgentRuns"]) {
|
||||||
|
assert.ok(Object.hasOwn(properties, key), `missing runtime schema property: ${key}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import plugin from "../dist/index.js";
|
||||||
|
import {
|
||||||
|
classifyRun,
|
||||||
|
decideToolCall,
|
||||||
|
resolveSenderId,
|
||||||
|
runKey,
|
||||||
|
} from "../dist/policy.js";
|
||||||
|
|
||||||
|
function registration(config = {}) {
|
||||||
|
const handlers = new Map();
|
||||||
|
plugin.register({
|
||||||
|
pluginConfig: config,
|
||||||
|
on(name, handler) {
|
||||||
|
handlers.set(name, handler);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
beforeAgentRun: handlers.get("before_agent_run"),
|
||||||
|
beforeToolCall: handlers.get("before_tool_call"),
|
||||||
|
agentEnd: handlers.get("agent_end"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentContext(id, senderId) {
|
||||||
|
return {
|
||||||
|
runId: `run-${id}`,
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionId: "session-uuid-123",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
...(senderId ? { senderId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolContext(id, extra = {}) {
|
||||||
|
return {
|
||||||
|
runId: `run-${id}`,
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionId: "session-uuid-123",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
|
||||||
|
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
|
||||||
|
assert.deepEqual(classifyRun({}, {}), {
|
||||||
|
trusted: false,
|
||||||
|
origin: "system_or_unknown",
|
||||||
|
reason: "sender_id_missing",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
classifyRun({ senderId: "blocked" }, { blockedSenderIds: ["blocked"] }).reason,
|
||||||
|
"sender_blocked",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
classifyRun(
|
||||||
|
{ senderId: "alice" },
|
||||||
|
{ requireExplicitTrustedSenders: true, trustedSenderIds: ["alice"] },
|
||||||
|
).trusted,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.deepEqual(classifyRun({ jobId: "job-1" }, {}), {
|
||||||
|
trusted: false,
|
||||||
|
origin: "automation",
|
||||||
|
reason: "job_id_present",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
|
||||||
|
const defaultHandlers = registration();
|
||||||
|
assert.deepEqual(
|
||||||
|
await defaultHandlers.beforeAgentRun({}, agentContext("default")),
|
||||||
|
{ outcome: "pass" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const enforced = registration({ blockUnknownAgentRuns: true });
|
||||||
|
const denied = await enforced.beforeAgentRun({}, agentContext("denied"));
|
||||||
|
assert.equal(denied.outcome, "block");
|
||||||
|
assert.equal(denied.reason, "sender_id_missing");
|
||||||
|
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await enforced.beforeAgentRun({}, { ...agentContext("job"), jobId: "job-1" }),
|
||||||
|
{ outcome: "pass" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("read-only tools pass without provenance and side effects fail with a stable reason", async () => {
|
||||||
|
assert.deepEqual(decideToolCall("read", undefined, {}), {
|
||||||
|
action: "allow",
|
||||||
|
code: "read_only",
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideToolCall("shell", undefined, {}), {
|
||||||
|
action: "block",
|
||||||
|
code: "run_provenance_missing",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlers = registration();
|
||||||
|
assert.equal(await handlers.beforeToolCall({ toolName: "read" }, {}), undefined);
|
||||||
|
const blocked = await handlers.beforeToolCall({ toolName: "shell" }, {});
|
||||||
|
assert.equal(blocked.block, true);
|
||||||
|
assert.equal(
|
||||||
|
blocked.blockReason,
|
||||||
|
"AIOS V2R1 origin policy denied tool call: run_provenance_missing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trusted human side effects request approval with exact timeout validation", async () => {
|
||||||
|
for (const [configured, expected] of [
|
||||||
|
[undefined, 60000],
|
||||||
|
[1000, 1000],
|
||||||
|
[300000, 300000],
|
||||||
|
[999, 60000],
|
||||||
|
[300001, 60000],
|
||||||
|
[60000.5, 60000],
|
||||||
|
["1000", 60000],
|
||||||
|
[null, 60000],
|
||||||
|
]) {
|
||||||
|
const config = {
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
};
|
||||||
|
if (configured !== undefined) config.approvalTimeoutMs = configured;
|
||||||
|
const handlers = registration(config);
|
||||||
|
const id = `approval-${String(configured)}`;
|
||||||
|
await handlers.beforeAgentRun({}, agentContext(id, "alice"));
|
||||||
|
const result = await handlers.beforeToolCall({ toolName: "shell" }, toolContext(id));
|
||||||
|
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
|
||||||
|
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active run provenance is isolated by registration, run, and sender", async () => {
|
||||||
|
const config = {
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice", "bob"],
|
||||||
|
};
|
||||||
|
const first = registration(config);
|
||||||
|
const second = registration(config);
|
||||||
|
await first.beforeAgentRun({}, agentContext("shared", "alice"));
|
||||||
|
|
||||||
|
const otherSender = await first.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("shared", { senderId: "bob" }),
|
||||||
|
);
|
||||||
|
assert.equal(otherSender.block, true);
|
||||||
|
assert.match(otherSender.blockReason, /run_scope_sender_conflict$/);
|
||||||
|
|
||||||
|
const otherRegistration = await second.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("shared"),
|
||||||
|
);
|
||||||
|
assert.equal(otherRegistration.block, true);
|
||||||
|
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent_end cleanup removes only the matching shared scope", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice", "bob"],
|
||||||
|
});
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("alice-scope", "alice"));
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("bob-scope", "bob"));
|
||||||
|
await handlers.agentEnd({}, toolContext("alice-scope"));
|
||||||
|
|
||||||
|
const alice = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("alice-scope"),
|
||||||
|
);
|
||||||
|
assert.equal(alice.block, true);
|
||||||
|
|
||||||
|
const bob = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("bob-scope"),
|
||||||
|
);
|
||||||
|
assert.ok(bob.requireApproval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run provenance expires after the bounded age", async (t) => {
|
||||||
|
const originalNow = Date.now;
|
||||||
|
t.after(() => { Date.now = originalNow; });
|
||||||
|
let now = 1_000_000;
|
||||||
|
Date.now = () => now;
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
});
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("expiring", "alice"));
|
||||||
|
now += 60 * 60 * 1000 + 1;
|
||||||
|
const result = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("expiring"),
|
||||||
|
);
|
||||||
|
assert.equal(result.block, true);
|
||||||
|
assert.match(result.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host-shaped lifecycle keeps distinct sessionId and sessionKey across all hooks", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
});
|
||||||
|
const before = agentContext("host-shape", "alice");
|
||||||
|
const tool = toolContext("host-shape");
|
||||||
|
const end = toolContext("host-shape");
|
||||||
|
for (const ctx of [before, tool, end]) {
|
||||||
|
assert.equal(ctx.sessionId, "session-uuid-123");
|
||||||
|
assert.equal(ctx.sessionKey, "agent:main:weixin:direct:user");
|
||||||
|
assert.equal(ctx.runId, "run-host-shape");
|
||||||
|
assert.equal(ctx.agentId, "agent-main");
|
||||||
|
}
|
||||||
|
|
||||||
|
await handlers.beforeAgentRun({}, before);
|
||||||
|
const result = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
tool,
|
||||||
|
);
|
||||||
|
assert.ok(result.requireApproval);
|
||||||
|
assert.equal(result.block, undefined);
|
||||||
|
await handlers.agentEnd({}, end);
|
||||||
|
const cleaned = await handlers.beforeToolCall({ toolName: "shell" }, tool);
|
||||||
|
assert.equal(cleaned.block, true);
|
||||||
|
assert.match(cleaned.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared scope collision fails closed without overwriting original sender", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice", "bob"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("collision", "alice")),
|
||||||
|
{ outcome: "pass" },
|
||||||
|
);
|
||||||
|
const collision = await handlers.beforeAgentRun(
|
||||||
|
{},
|
||||||
|
agentContext("collision", "bob"),
|
||||||
|
);
|
||||||
|
assert.equal(collision.outcome, "block");
|
||||||
|
assert.equal(collision.reason, "run_scope_sender_conflict");
|
||||||
|
assert.doesNotMatch(collision.message, /alice|bob/);
|
||||||
|
|
||||||
|
const original = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("collision"),
|
||||||
|
);
|
||||||
|
assert.ok(original.requireApproval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared scope normalization is exact and incomplete scope fails closed", async () => {
|
||||||
|
const bothAliases = runKey({}, {
|
||||||
|
runId: " run-1 ",
|
||||||
|
agentId: " agent-1 ",
|
||||||
|
sessionId: " session-uuid-123 ",
|
||||||
|
sessionKey: " agent:main:weixin:direct:user ",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
bothAliases,
|
||||||
|
JSON.stringify(["run-1", "agent-1", "session-uuid-123"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = runKey(
|
||||||
|
{ sessionKey: " agent:main:weixin:direct:user " },
|
||||||
|
{
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fallback,
|
||||||
|
JSON.stringify(["run-1", "agent-1", "agent:main:weixin:direct:user"]),
|
||||||
|
);
|
||||||
|
assert.equal(runKey(
|
||||||
|
{ sessionId: "event-session" },
|
||||||
|
{
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionId: "context-session",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
},
|
||||||
|
), undefined);
|
||||||
|
assert.equal(runKey({}, {
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
sessionId: "session-uuid-123",
|
||||||
|
}), bothAliases);
|
||||||
|
assert.equal(runKey({}, {
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
}), fallback);
|
||||||
|
assert.equal(runKey({}, {
|
||||||
|
agentId: "agent-1", sessionId: "session-uuid-123",
|
||||||
|
}), undefined);
|
||||||
|
|
||||||
|
const handlers = registration();
|
||||||
|
const missing = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
{ agentId: "agent-1", sessionId: "session-uuid-123" },
|
||||||
|
);
|
||||||
|
assert.equal(missing.block, true);
|
||||||
|
assert.match(missing.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessionKey fallback requires sessionId absent from both hook contexts", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
});
|
||||||
|
const before = {
|
||||||
|
runId: "run-fallback",
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
senderId: "alice",
|
||||||
|
};
|
||||||
|
const tool = {
|
||||||
|
runId: "run-fallback",
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionKey: "agent:main:weixin:direct:user",
|
||||||
|
};
|
||||||
|
await handlers.beforeAgentRun({}, before);
|
||||||
|
assert.ok((await handlers.beforeToolCall({ toolName: "shell" }, tool)).requireApproval);
|
||||||
|
|
||||||
|
const mismatchedSelection = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
{ ...tool, sessionId: "session-uuid-123" },
|
||||||
|
);
|
||||||
|
assert.equal(mismatchedSelection.block, true);
|
||||||
|
assert.match(mismatchedSelection.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-named event and context sessionId mismatch fails closed", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
});
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("event-mismatch", "alice"));
|
||||||
|
const result = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell", sessionId: "different-session" },
|
||||||
|
toolContext("event-mismatch"),
|
||||||
|
);
|
||||||
|
assert.equal(result.block, true);
|
||||||
|
assert.match(result.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from active_runtime.app.agent_event_bus import AgentEventBus
|
from app.agent_event_bus import AgentEventBus
|
||||||
from active_runtime.app.agent_event_dashboard import AgentEventDashboard
|
from app.agent_event_dashboard import AgentEventDashboard
|
||||||
|
|
||||||
|
|
||||||
def test_unified_event_flow():
|
def test_unified_event_flow():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.agent_observability_hooks import AgentObservabilityHooks
|
from app.agent_observability_hooks import AgentObservabilityHooks
|
||||||
|
|
||||||
|
|
||||||
def test_agent_observability_closed_loop():
|
def test_agent_observability_closed_loop():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from active_runtime.app.architect_governance_agent import ArchitectGovernanceAgent
|
from app.architect_governance_agent import ArchitectGovernanceAgent
|
||||||
from active_runtime.app.architect_governance_dashboard import ArchitectGovernanceDashboard
|
from app.architect_governance_dashboard import ArchitectGovernanceDashboard
|
||||||
|
|
||||||
|
|
||||||
def test_architect_agent_decides_release_level():
|
def test_architect_agent_decides_release_level():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.architect_research_timeline import ArchitectResearchTimeline
|
from app.architect_research_timeline import ArchitectResearchTimeline
|
||||||
|
|
||||||
|
|
||||||
def test_research_timeline_tracks_version_decision():
|
def test_research_timeline_tracks_version_decision():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.architect_weekly_research_agent import ArchitectWeeklyResearchAgent
|
from app.architect_weekly_research_agent import ArchitectWeeklyResearchAgent
|
||||||
|
|
||||||
|
|
||||||
def test_architect_research_decides_release_type():
|
def test_architect_research_decides_release_type():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.architecture_change_classifier import ArchitectureChangeClassifier
|
from app.architecture_change_classifier import ArchitectureChangeClassifier
|
||||||
|
|
||||||
|
|
||||||
def test_small_change_can_auto_process():
|
def test_small_change_can_auto_process():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.architecture_owner_gate import ArchitectureOwnerGate
|
from app.architecture_owner_gate import ArchitectureOwnerGate
|
||||||
|
|
||||||
|
|
||||||
def test_architecture_change_blocks_without_owner():
|
def test_architecture_change_blocks_without_owner():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.change_governance_dashboard import ChangeGovernanceDashboard
|
from app.change_governance_dashboard import ChangeGovernanceDashboard
|
||||||
|
|
||||||
|
|
||||||
def test_change_governance_blocks_missing_evidence():
|
def test_change_governance_blocks_missing_evidence():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.governance_event_bridge import GovernanceEventBridge
|
from app.governance_event_bridge import GovernanceEventBridge
|
||||||
|
|
||||||
|
|
||||||
def test_governance_events_are_published():
|
def test_governance_events_are_published():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.governance_timeline_dashboard import GovernanceTimelineDashboard
|
from app.governance_timeline_dashboard import GovernanceTimelineDashboard
|
||||||
|
|
||||||
|
|
||||||
def test_governance_timeline_detects_ready_flow():
|
def test_governance_timeline_detects_ready_flow():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.search_agent_adapter import AgentSearchContext, OpenClawSearchClient
|
from app.search_agent_adapter import AgentSearchContext, OpenClawSearchClient
|
||||||
|
|
||||||
|
|
||||||
class FakeOrchestrator:
|
class FakeOrchestrator:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.search_architect_integration import SearchArchitectIntegration
|
from app.search_architect_integration import SearchArchitectIntegration
|
||||||
|
|
||||||
|
|
||||||
def test_search_architect_recommends_release_for_new_capability():
|
def test_search_architect_recommends_release_for_new_capability():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.search_gateway import (
|
from app.search_gateway import (
|
||||||
SearchEvidence,
|
SearchEvidence,
|
||||||
SearchGateway,
|
SearchGateway,
|
||||||
SearchProviderAttempt,
|
SearchProviderAttempt,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.search_gateway_dashboard import SearchGatewayDashboard
|
from app.search_gateway_dashboard import SearchGatewayDashboard
|
||||||
|
|
||||||
|
|
||||||
def test_search_gateway_dashboard_reports_provider_failures():
|
def test_search_gateway_dashboard_reports_provider_failures():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from active_runtime.app.search_gateway import SearchEvidence, SearchGateway, SearchProviderAttempt, SearchRequest
|
from app.search_gateway import SearchEvidence, SearchGateway, SearchProviderAttempt, SearchRequest
|
||||||
from active_runtime.app.search_profile_resolver import SearchProfileResolver
|
from app.search_profile_resolver import SearchProfileResolver
|
||||||
|
|
||||||
|
|
||||||
class FailingProvider:
|
class FailingProvider:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
from app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
|
||||||
|
|
||||||
def test_search_orchestrator_has_audit_registry_event_layers():
|
def test_search_orchestrator_has_audit_registry_event_layers():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from active_runtime.app.search_provider_registry import SearchProviderRegistry, StaticArchiveProvider
|
from app.search_provider_registry import SearchProviderRegistry, StaticArchiveProvider
|
||||||
from active_runtime.app.search_gateway import SearchGateway, SearchRequest
|
from app.search_gateway import SearchGateway, SearchRequest
|
||||||
|
|
||||||
|
|
||||||
def test_provider_registry_has_default_layers():
|
def test_provider_registry_has_default_layers():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.task_center import AIOSTaskRecord, TaskCenter, TaskNotificationStatus, TaskProgress
|
from app.task_center import AIOSTaskRecord, TaskCenter, TaskNotificationStatus, TaskProgress
|
||||||
|
|
||||||
|
|
||||||
def test_task_center_tracks_progress_and_notification():
|
def test_task_center_tracks_progress_and_notification():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.task_dashboard_view import TaskDashboardView
|
from app.task_dashboard_view import TaskDashboardView
|
||||||
|
|
||||||
|
|
||||||
def test_task_dashboard_contains_operations_views():
|
def test_task_dashboard_contains_operations_views():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from active_runtime.app.task_event_bridge import TaskEventBridge
|
from app.task_event_bridge import TaskEventBridge
|
||||||
from active_runtime.app.task_statistics import TaskStatistics
|
from app.task_statistics import TaskStatistics
|
||||||
|
|
||||||
|
|
||||||
def test_task_event_bridge_creates_events():
|
def test_task_event_bridge_creates_events():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.task_lifecycle import TaskLifecycle
|
from app.task_lifecycle import TaskLifecycle
|
||||||
|
|
||||||
|
|
||||||
def test_owner_approval_button_appears_for_waiting_task():
|
def test_owner_approval_button_appears_for_waiting_task():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.task_orchestrator import TaskOrchestrator
|
from app.task_orchestrator import TaskOrchestrator
|
||||||
|
|
||||||
|
|
||||||
def test_command_creates_task():
|
def test_command_creates_task():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from active_runtime.app.git_repo_locator import GitRepoLocator
|
from app.git_repo_locator import GitRepoLocator
|
||||||
|
|
||||||
|
|
||||||
def test_repo_locator_uses_configurable_root():
|
def test_repo_locator_uses_configurable_root():
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import aios_v2r1
|
||||||
|
from aios_v2r1 import cli
|
||||||
|
from aios_v2r1.ingress_service import IngressHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_package_has_one_identity_and_no_compatibility_shim() -> None:
|
||||||
|
assert aios_v2r1.__name__ == "aios_v2r1"
|
||||||
|
assert aios_v2r1.__package__ == "aios_v2r1"
|
||||||
|
assert aios_v2r1.__version__ == "2.1.0-rc.1"
|
||||||
|
assert importlib.util.find_spec("active_runtime") is None
|
||||||
|
assert not (ROOT / "active_runtime").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaging_metadata_declares_only_the_canonical_app_package() -> None:
|
||||||
|
metadata = (ROOT / "active-runtime" / "pyproject.toml").read_text()
|
||||||
|
assert 'packages = ["aios_v2r1"]' in metadata
|
||||||
|
assert 'package-dir = {"" = "app"}' in metadata
|
||||||
|
assert "active_runtime" not in metadata
|
||||||
|
assert 'aios-cell = "aios_v2r1.cli:main"' in metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_path_named_serve_keeps_legacy_status_dispatch(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
tenant_id: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
def __init__(self, registry: str) -> None:
|
||||||
|
calls.append(("registry", registry))
|
||||||
|
|
||||||
|
def status(self, tenant_id: str) -> Result:
|
||||||
|
calls.append(("status", tenant_id))
|
||||||
|
return Result(tenant_id=tenant_id, state="healthy")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "CellManager", FakeManager)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli,
|
||||||
|
"serve",
|
||||||
|
lambda host, port: pytest.fail("legacy registry command entered service mode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cli.main(["serve", "status", "tenant-a"]) == 0
|
||||||
|
assert calls == [("registry", "serve"), ("status", "tenant-a")]
|
||||||
|
assert '"tenant_id": "tenant-a"' in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaged_server_waits_for_non_daemon_request_handlers() -> None:
|
||||||
|
assert IngressHTTPServer.daemon_threads is False
|
||||||
|
assert IngressHTTPServer.block_on_close is True
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user