""" Exec-kernel HTTP server (ADR-0007 Phase 1). A minimal HTTP service that RUNS INSIDE the sandbox (a container in prod; a localhost subprocess in dev/test). It holds ONE persistent per-session namespace — reusing the shared `NamespaceKernel` so its exec/stdout-capture/seeding semantics are byte-for-byte identical to the in-process `PythonExecutor` — and exposes it over a small JSON protocol the `SandboxedExecutor` client drives. Design choices matching the ADR + house style: * stdlib `http.server` only (no flask/uvicorn dep) → the kernel imports and runs anywhere, including this dep-light build environment. * Binds to localhost only (127.0.0.1) — never a routable interface. In the container the client reaches it via the container's published localhost port. * The only thing that crosses the boundary is captured stdout (a string) + small JSON control messages — trivially serializable, per the ADR. * MCP tool NAMES are injected via `send_functions`; the kernel resolves them to stubs that call the vetted MCP HTTP server (see `mcp_bridge`), so tools stay OUTSIDE the sandbox and only untrusted code is confined. Endpoints (all POST unless noted; JSON in / JSON out): GET /health -> {"status": "ok", "session": ...} POST /reset -> {"status": "ok"} (fresh namespace) POST /send_functions {"tools":[...]} -> {"status":"ok","registered":[...]} (tool NAMES/schemas → in-namespace MCP stubs) POST /send_variables {"variables": {...}} -> {"status":"ok"} (JSON-safe variables only; complex objects stay out — the agent's cross-step state lives in the namespace via code) POST /execute {"code": "..."} -> {"stdout": ""} Run standalone (BY FILE PATH — not `-m`, which would drag the full `managers` package/agent-stack into the sandbox; running the file as a top-level script skips every package `__init__`, and the import fallback below loads its siblings): python src/managers/execution/sandbox/kernel.py --port 8790 [--mcp-url URL] """ from __future__ import annotations import argparse import json import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any # Import the shared core by absolute-or-relative path so the kernel works both as # `python -m ...sandbox.kernel` (package context) and when launched from a file # path inside a bare container (no package parent). Try the package-relative # import first; fall back to loading the sibling core module by file location. try: # normal package import from ..namespace_kernel import NamespaceKernel from . import mcp_bridge except ImportError: # pragma: no cover - container/file-launch fallback import importlib.util import pathlib _here = pathlib.Path(__file__).resolve().parent _nk_spec = importlib.util.spec_from_file_location( "namespace_kernel", _here.parent / "namespace_kernel.py" ) NamespaceKernel = importlib.util.module_from_spec(_nk_spec) # type: ignore _nk_spec.loader.exec_module(NamespaceKernel) # type: ignore NamespaceKernel = NamespaceKernel.NamespaceKernel # type: ignore _mb_spec = importlib.util.spec_from_file_location("mcp_bridge", _here / "mcp_bridge.py") mcp_bridge = importlib.util.module_from_spec(_mb_spec) # type: ignore _mb_spec.loader.exec_module(mcp_bridge) # type: ignore class KernelState: """Holds the single per-session namespace + the MCP URL for tool stubs.""" def __init__(self, session_id: str, mcp_url: str | None) -> None: self.session_id = session_id self.mcp_url = mcp_url self.kernel = NamespaceKernel() def reset(self) -> None: self.kernel.reset() def send_functions(self, tools) -> list: """Register injected tool NAMES as in-namespace MCP-dispatching stubs. `tools` is a list of names or `{"name","description"}` dicts. If no MCP URL is configured, we still register no-op-safe stubs that fail loudly when called (so a mis-wired sandbox surfaces the cause, not a NameError). """ mcp_url = self.mcp_url if not mcp_url: # Build stubs that raise a clear error on call rather than silently # missing — mirrors the "loud stub" posture elsewhere in the repo. def _unwired(name): def _stub(**kwargs): raise mcp_bridge.MCPToolError( f"Tool {name!r} injected but no MCP_URL configured on the " "sandbox kernel — cannot dispatch." ) _stub.__name__ = name _stub.__mcp_tool__ = True return _stub names = [t if isinstance(t, str) else t.get("name") for t in tools] stubs = {n: _unwired(n) for n in names if n} else: stubs = mcp_bridge.build_tool_stubs(mcp_url, tools) self.kernel.send_functions(stubs) return sorted(stubs.keys()) def send_variables(self, variables: dict[str, Any]) -> None: self.kernel.send_variables(variables or {}) def execute(self, code: str) -> str: return self.kernel.execute(code) def _make_handler(state: KernelState): class KernelHandler(BaseHTTPRequestHandler): # Silence the default per-request stderr logging (noisy in tests/logs). def log_message(self, *args, **kwargs): # noqa: D401 return def _send_json(self, obj: dict, status: int = 200) -> None: body = json.dumps(obj).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json(self) -> dict: length = int(self.headers.get("Content-Length", 0) or 0) if not length: return {} raw = self.rfile.read(length) try: return json.loads(raw.decode("utf-8")) except Exception: return {} def do_GET(self): # noqa: N802 if self.path.rstrip("/") == "/health": self._send_json({"status": "ok", "session": state.session_id}) else: self._send_json({"error": f"unknown path {self.path}"}, status=404) def do_POST(self): # noqa: N802 path = self.path.rstrip("/") or "/" try: data = self._read_json() if path == "/reset": state.reset() self._send_json({"status": "ok"}) elif path == "/send_functions": registered = state.send_functions(data.get("tools", [])) self._send_json({"status": "ok", "registered": registered}) elif path == "/send_variables": state.send_variables(data.get("variables", {})) self._send_json({"status": "ok"}) elif path == "/execute": stdout = state.execute(data.get("code", "")) self._send_json({"stdout": stdout}) else: self._send_json({"error": f"unknown path {self.path}"}, status=404) except Exception as exc: # never leak a traceback over the wire self._send_json({"error": f"{type(exc).__name__}: {exc}"}, status=500) return KernelHandler def build_server(port: int, session_id: str, mcp_url: str | None, host: str = "127.0.0.1"): """Construct (but do not serve) the kernel HTTP server. Returns (server, state). Bind to localhost only. Passing port 0 lets the OS pick a free port (the chosen port is readable via `server.server_address[1]`) — used by tests. """ state = KernelState(session_id=session_id, mcp_url=mcp_url) handler = _make_handler(state) server = ThreadingHTTPServer((host, port), handler) return server, state def main(argv=None) -> None: parser = argparse.ArgumentParser(description="ADR-0007 sandbox exec-kernel") parser.add_argument( "--port", type=int, default=int(os.environ.get("SANDBOX_KERNEL_PORT", "8790")) ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--session-id", default=os.environ.get("SANDBOX_SESSION_ID", "default")) parser.add_argument("--mcp-url", default=os.environ.get("SANDBOX_MCP_URL") or None) args = parser.parse_args(argv) server, _ = build_server(args.port, args.session_id, args.mcp_url, host=args.host) print( f"[sandbox-kernel] serving on http://{args.host}:{server.server_address[1]}/ " f"(session={args.session_id}, mcp_url={args.mcp_url})", flush=True, ) try: server.serve_forever() except KeyboardInterrupt: # pragma: no cover pass finally: server.server_close() if __name__ == "__main__": main()