File size: 9,109 Bytes
a495d22 c3b49d6 a495d22 c3b49d6 a495d22 c3b49d6 a495d22 c3b49d6 a495d22 c3b49d6 a495d22 c3b49d6 a495d22 c3b49d6 a495d22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """
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()
|