Annie Voigt
style: apply ruff lint --fix + ruff format across the tree
c3b49d6
Raw
History Blame Contribute Delete
9.11 kB
"""
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
# 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()