| """ |
| 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": "<captured>"} |
| |
| 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 |
|
|
| |
| |
| |
| |
| try: |
| from ..namespace_kernel import NamespaceKernel |
| from . import mcp_bridge |
| except ImportError: |
| 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) |
| _nk_spec.loader.exec_module(NamespaceKernel) |
| NamespaceKernel = NamespaceKernel.NamespaceKernel |
| _mb_spec = importlib.util.spec_from_file_location("mcp_bridge", _here / "mcp_bridge.py") |
| mcp_bridge = importlib.util.module_from_spec(_mb_spec) |
| _mb_spec.loader.exec_module(mcp_bridge) |
|
|
|
|
| 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: |
| |
| |
| 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): |
| |
| def log_message(self, *args, **kwargs): |
| 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): |
| 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): |
| 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: |
| 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: |
| pass |
| finally: |
| server.server_close() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|