From 33aa892527fc0bf62d47f70cdb2d96927a700e14 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 16:36:49 -0700 Subject: [PATCH 1/8] feat(examples): add Jupyter sandbox fleet Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/Dockerfile | 28 + examples/jupyter-sandbox/README.md | 129 +++++ examples/jupyter-sandbox/demo.py | 57 ++ examples/jupyter-sandbox/fleet.py | 74 +++ examples/jupyter-sandbox/jupyter_sandbox.py | 569 ++++++++++++++++++++ examples/jupyter-sandbox/policy.yaml | 20 + 6 files changed, 877 insertions(+) create mode 100644 examples/jupyter-sandbox/Dockerfile create mode 100644 examples/jupyter-sandbox/README.md create mode 100644 examples/jupyter-sandbox/demo.py create mode 100644 examples/jupyter-sandbox/fleet.py create mode 100644 examples/jupyter-sandbox/jupyter_sandbox.py create mode 100644 examples/jupyter-sandbox/policy.yaml diff --git a/examples/jupyter-sandbox/Dockerfile b/examples/jupyter-sandbox/Dockerfile new file mode 100644 index 0000000000..a0cb7767c1 --- /dev/null +++ b/examples/jupyter-sandbox/Dockerfile @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM ghcr.io/astral-sh/uv:0.11.28 AS uv +FROM python:3.13-slim + +COPY --from=uv /uv /uvx /bin/ + +# OpenShell requires iproute2 for network isolation. nftables enables bypass +# detection, while procps is useful for inspecting the notebook processes. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates iproute2 nftables procps \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --no-cache \ + jupyter-server==2.20.0 \ + ipykernel==7.3.0 + +RUN groupadd --gid 1000 sandbox \ + && useradd --uid 1000 --gid sandbox --home-dir /sandbox \ + --no-create-home --shell /bin/sh sandbox \ + && install -d -o sandbox -g sandbox /sandbox +WORKDIR /sandbox + +EXPOSE 8888 + +# OpenShell replaces the image command with the sandbox supervisor. The +# example starts Jupyter after sandbox creation through the Python SDK. diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md new file mode 100644 index 0000000000..723bd49f79 --- /dev/null +++ b/examples/jupyter-sandbox/README.md @@ -0,0 +1,129 @@ +# Jupyter Sandbox Fleet + +Launch several OpenShell sandboxes, expose each Jupyter API server as a named +OpenShell service, and submit Python code to one member through Jupyter's REST +and kernel WebSocket APIs. + +The example keeps the fleet abstraction separate from the sandbox type: +`Fleet[T]` can compose any context-managed resource, while `JupyterSandbox` +owns one sandbox's OpenShell, Jupyter, service, and cleanup lifecycle. It uses +the OpenShell Python SDK for gateway health, sandbox creation, readiness, +command execution, and deletion. + +## Prerequisites + +- A running local OpenShell gateway (`mise run gateway:docker` for development) +- The `openshell` CLI configured to use that gateway for the temporary service + expose/delete fallback +- Docker to build the example image +- Python 3.11 or later with `openshell`, `pyyaml`, and `websocket-client` +- [uv](https://docs.astral.sh/uv/) to install the Python dependencies + +The example intentionally supports a local gateway only. Remote service access +requires handling the gateway's authentication boundary and is outside this +example's scope. + +## Launch three sandboxes + +Install the Python dependencies, build the Jupyter-ready image once, and run +the script: + +```shell +cd examples/jupyter-sandbox +uv venv +source .venv/bin/activate +uv pip install openshell pyyaml websocket-client +docker build -t openshell-jupyter-sandbox:local . +python demo.py +``` + +The demo performs these operations: + +1. Verifies the selected gateway through `SandboxClient.health()`. +2. Creates three sandboxes from the configured image and policy through the + Python SDK. +3. Starts Jupyter Server on `127.0.0.1:8888` in each sandbox. +4. Exposes every server as an OpenShell service named `jupyter`. +5. Creates a kernel in the first sandbox and executes this code over the + Jupyter API: + + ```python + print(sum(i * i for i in range(10))) + ``` + +6. Deletes every service and sandbox when the context exits, including after + an exception. Sandbox deletion uses the Python SDK. + +The expected result is `285`. + +## Configure the fleet + +Edit the configuration constants at the top of `demo.py` to select the sandbox +image, fleet size, policy, names, gateway, and work: + +```python +IMAGE = "registry.example.com/jupyter-sandbox:latest" +SANDBOX_COUNT = 5 +POLICY = EXAMPLE_DIR / "my-policy.yaml" +NAME_PREFIX = "analysis" +CODE = 'print("hello from Jupyter")' +GATEWAY = None +``` + +`IMAGE` accepts an OCI image reference. The Python SDK does not currently +build a Dockerfile the way `openshell sandbox create --from` does, so build or +publish the image first. A custom image must provide `jupyter server` and a +`python3` kernel. It must also contain a non-root `sandbox` user and group +because the included policy selects that process identity. The included +Dockerfile uses `python:3.13-slim`, creates that identity, and installs pinned +Jupyter dependencies. + +`policy.yaml` allows the system paths Jupyter needs and writable access to +`/sandbox` and `/tmp`. Its empty `network_policies` map denies outbound network +access. Exposing the loopback Jupyter server through OpenShell is inbound and +does not require an egress rule. Replace the policy when notebook work needs +explicit outbound destinations. + +## Reuse the abstraction + +The core composition is deliberately small: + +```python +with Fleet( + count=3, + factory=lambda index: JupyterSandbox( + client=client, + name=f"jupyter-{run_id}-{index + 1}", + image=image, + policy=policy, + ), +) as fleet: + print(fleet[0].execute("print(sum(i * i for i in range(10)))")) +``` + +Each sandbox gets a unique Jupyter token. The token travels to the sandbox over +standard input, is stored in a mode-restricted file, and is attached internally +to Jupyter API requests. The example never prints token-bearing URLs. OpenShell +service URLs shown by the demo are safe to display but still require the +process-local token for Jupyter API access. + +## Proposed Python SDK APIs + +This example still has deliberate seams because the SDK does not expose +all of the CLI's functionality: + +- Add `SandboxClient.expose_service()`, `get_service()`, `list_services()`, and + `delete_service()`, returning a public `ServiceEndpoint` model. The example + currently uses the `openshell service` CLI only for expose and delete. +- Add a public sandbox configuration builder that accepts `image` and a + `SandboxPolicy`. The low-level client currently requires generated protobuf + types from the private `openshell._proto` package. +- Add `load_sandbox_policy()` with the same canonical YAML validation and + conversion as the CLI. This example only normalizes the + `filesystem_policy` field needed by its policy before parsing the protobuf. +- Add an image-source helper with CLI parity for OCI references, Dockerfiles, + and build directories. Until then, Python SDK callers must prepare the OCI + image before creating a sandbox. + +With those APIs, `JupyterSandbox` could remove its CLI subprocess adapter and +all imports from `openshell._proto`. diff --git a/examples/jupyter-sandbox/demo.py b/examples/jupyter-sandbox/demo.py new file mode 100644 index 0000000000..5ab77e5290 --- /dev/null +++ b/examples/jupyter-sandbox/demo.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Launch a fleet of Jupyter sandboxes and submit code to the first member.""" + +from __future__ import annotations + +import secrets +from pathlib import Path + +from fleet import Fleet +from jupyter_sandbox import JupyterSandbox + +from openshell import SandboxClient + +EXAMPLE_DIR = Path(__file__).resolve().parent + +# Configure the fleet here. +IMAGE = "openshell-jupyter-sandbox:local" +SANDBOX_COUNT = 3 +POLICY = EXAMPLE_DIR / "policy.yaml" +NAME_PREFIX = "jupyter" +CODE = "print(sum(i * i for i in range(10)))" +GATEWAY: str | None = None # None selects the active OpenShell gateway. +OPENSHELL_BIN = "openshell" # Used only for service APIs missing from the SDK. + + +def main() -> None: + with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: + health = client.health() + print(f"Connected to OpenShell {health.version}") + + run_id = secrets.token_hex(3) + with Fleet( + count=SANDBOX_COUNT, + factory=lambda index: JupyterSandbox( + client=client, + name=f"{NAME_PREFIX}-{run_id}-{index + 1}", + image=IMAGE, + policy=POLICY, + openshell_bin=OPENSHELL_BIN, + cluster=GATEWAY, + ), + ) as fleet: + print(f"\nStarted {len(fleet)} Jupyter sandboxes:") + for sandbox in fleet: + print(f" {sandbox.name}: {sandbox.service_url}") + + print(f"\nSubmitting code to {fleet[0].name} over the Jupyter API:") + print(CODE) + result = fleet[0].execute(CODE) + print("\nResult:") + print(result, end="" if result.endswith("\n") else "\n") + + +if __name__ == "__main__": + main() diff --git a/examples/jupyter-sandbox/fleet.py b/examples/jupyter-sandbox/fleet.py new file mode 100644 index 0000000000..cd18fb6389 --- /dev/null +++ b/examples/jupyter-sandbox/fleet.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A small, generic abstraction for managing a fleet of context managers.""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Iterator, Sequence +from contextlib import AbstractContextManager, ExitStack +from typing import Generic, TypeVar, overload + +T = TypeVar("T") +MemberFactory = Callable[[int], AbstractContextManager[T]] + + +class Fleet(Sequence[T], AbstractContextManager["Fleet[T]"], Generic[T]): + """Create and clean up a fixed number of context-managed members.""" + + def __init__(self, *, count: int, factory: MemberFactory[T]) -> None: + if count < 1: + raise ValueError("count must be at least 1") + self._count = count + self._factory = factory + self._members: list[T] = [] + self._stack: ExitStack | None = None + + def __enter__(self) -> Fleet[T]: + if self._stack is not None: + raise RuntimeError("fleet is already running") + + stack = ExitStack() + stack.__enter__() + self._stack = stack + try: + for index in range(self._count): + self._members.append(stack.enter_context(self._factory(index))) + except BaseException: + self._members.clear() + self._stack = None + stack.__exit__(*sys.exc_info()) + raise + return self + + def __exit__(self, exc_type, exc_value, traceback) -> bool | None: + stack = self._stack + if stack is None: + raise RuntimeError("fleet is not running") + + try: + return stack.__exit__(exc_type, exc_value, traceback) + finally: + self._members.clear() + self._stack = None + + def _running_members(self) -> list[T]: + if self._stack is None: + raise RuntimeError("fleet members are only available inside the context") + return self._members + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> Sequence[T]: ... + + def __getitem__(self, index: int | slice) -> T | Sequence[T]: + return self._running_members()[index] + + def __len__(self) -> int: + return len(self._running_members()) + + def __iter__(self) -> Iterator[T]: + return iter(self._running_members()) diff --git a/examples/jupyter-sandbox/jupyter_sandbox.py b/examples/jupyter-sandbox/jupyter_sandbox.py new file mode 100644 index 0000000000..7ed406e27b --- /dev/null +++ b/examples/jupyter-sandbox/jupyter_sandbox.py @@ -0,0 +1,569 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""One OpenShell sandbox exposing a token-authenticated Jupyter API service.""" + +from __future__ import annotations + +import importlib +import json +import os +import re +import secrets +import struct +import subprocess +import time +import uuid +import warnings +from contextlib import AbstractContextManager +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import ProxyHandler, Request, build_opener + +import yaml +from google.protobuf.json_format import ParseDict, ParseError + +from openshell._proto import openshell_pb2, sandbox_pb2 + +if TYPE_CHECKING: + from openshell import ExecResult, SandboxClient, SandboxSession + +JUPYTER_PORT = 8888 +JUPYTER_SERVICE = "jupyter" +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") +_SERVICE_URL = re.compile(r"^\s*URL:\s+(\S+)\s*$", re.MULTILINE) +_START_JUPYTER = r""" +set -eu +umask 077 +token_file=/tmp/openshell-jupyter-token +runtime_dir=/tmp/openshell-jupyter-runtime +mkdir -p "$runtime_dir" +IFS= read -r token +printf '%s' "$token" > "$token_file" +export HOME=/sandbox +export JUPYTER_RUNTIME_DIR="$runtime_dir" +export JUPYTER_TOKEN_FILE="$token_file" +nohup jupyter server \ + --ServerApp.ip=127.0.0.1 \ + --ServerApp.port=8888 \ + --ServerApp.port_retries=0 \ + --ServerApp.open_browser=False \ + --ServerApp.root_dir=/sandbox \ + --ServerApp.terminals_enabled=False \ + /tmp/openshell-jupyter.log 2>&1 & +""".strip() + + +class JupyterSandboxError(RuntimeError): + """An OpenShell or Jupyter lifecycle operation failed.""" + + +class JupyterExecutionError(JupyterSandboxError): + """Code submitted to a Jupyter kernel failed.""" + + +class JupyterSandbox(AbstractContextManager["JupyterSandbox"]): + """Manage one sandbox, its Jupyter server, and its exposed service.""" + + def __init__( + self, + *, + client: SandboxClient, + name: str, + image: str | Path, + policy: str | Path, + openshell_bin: str = "openshell", + cluster: str | None = None, + ready_timeout: float = 60.0, + execute_timeout: float = 60.0, + ) -> None: + if len(name) > 28 or not _NAME.fullmatch(name): + raise ValueError( + "name must be at most 28 lowercase letters, digits, or hyphens" + ) + if ready_timeout <= 0 or execute_timeout <= 0: + raise ValueError("timeouts must be greater than zero") + + policy_path = Path(policy).expanduser().resolve() + if not policy_path.is_file(): + raise ValueError(f"policy does not exist: {policy_path}") + + image_reference = str(image) + if Path(image_reference).expanduser().exists(): + raise ValueError( + "the Python SDK requires an OCI image reference; build the " + "Dockerfile before launching the fleet" + ) + + self.client = client + self.name = name + self.image = image_reference + self.policy = policy_path + self.openshell_bin = openshell_bin + self.cluster = cluster + self.ready_timeout = ready_timeout + self.execute_timeout = execute_timeout + self.service_url: str | None = None + + self._token = secrets.token_urlsafe(32) + self._session: SandboxSession | None = None + self._sandbox_create_attempted = False + self._sandbox_created = False + self._service_created = False + + def __enter__(self) -> JupyterSandbox: + try: + self._create_sandbox() + self._start_jupyter() + self.service_url = self._expose_service() + self._wait_until_ready() + except BaseException as error: + cleanup_errors = self._cleanup() + self._attach_cleanup_errors(error, cleanup_errors) + raise + return self + + def __exit__(self, exc_type, exc_value, traceback) -> bool: + cleanup_errors = self._cleanup() + if not cleanup_errors: + return False + if exc_value is not None: + self._attach_cleanup_errors(exc_value, cleanup_errors) + return False + raise BaseExceptionGroup( + f"failed to clean up Jupyter sandbox {self.name}", cleanup_errors + ) + + def execute(self, code: str) -> str: + """Execute code through the public Jupyter REST and WebSocket APIs.""" + if self.service_url is None: + raise RuntimeError("sandbox is only available inside the context") + + kernel = self._request_json( + "POST", "/api/kernels", {"name": "python3", "path": ""} + ) + kernel_id = kernel.get("id") + if not isinstance(kernel_id, str): + raise JupyterSandboxError("Jupyter did not return a kernel id") + + execution_error: BaseException | None = None + try: + return self._execute_on_kernel(kernel_id, code) + except BaseException as error: + execution_error = error + raise + finally: + try: + self._request("DELETE", f"/api/kernels/{kernel_id}") + except BaseException as error: + if execution_error is None: + raise + warnings.warn( + self._safe_message("failed to delete Jupyter kernel", error), + stacklevel=2, + ) + + def _create_sandbox(self) -> None: + self._sandbox_create_attempted = True + self._session = self.client.create_session( + name=self.name, + labels={"app": "jupyter", "managed-by": "jupyter-sandbox-example"}, + spec=self._sandbox_spec(), + ) + self._sandbox_created = True + self.client.wait_ready(self.name, timeout_seconds=self.ready_timeout) + + def _start_jupyter(self) -> None: + self._exec( + ["/bin/sh", "-c", _START_JUPYTER], + stdin=f"{self._token}\n".encode(), + ) + + def _sandbox_spec(self) -> openshell_pb2.SandboxSpec: + try: + raw_policy = yaml.safe_load(self.policy.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as error: + raise JupyterSandboxError( + self._safe_message(f"could not load policy {self.policy}", error) + ) from None + if not isinstance(raw_policy, dict): + raise JupyterSandboxError("sandbox policy must be a YAML mapping") + + # The canonical policy file calls this field `filesystem_policy`, while + # the protobuf field is `filesystem`. The SDK does not yet expose the + # CLI's canonical YAML loader, so normalize the one alias used here. + policy_data = dict(raw_policy) + filesystem = policy_data.pop("filesystem_policy", None) + if filesystem is not None: + policy_data["filesystem"] = filesystem + try: + policy = ParseDict(policy_data, sandbox_pb2.SandboxPolicy()) + except (ParseError, TypeError, ValueError) as error: + raise JupyterSandboxError( + self._safe_message(f"invalid sandbox policy {self.policy}", error) + ) from None + + return openshell_pb2.SandboxSpec( + template=openshell_pb2.SandboxTemplate(image=self.image), + policy=policy, + providers=[], + ) + + def _expose_service(self) -> str: + completed = self._run_service( + "service", + "expose", + self.name, + str(JUPYTER_PORT), + JUPYTER_SERVICE, + capture=True, + ) + self._service_created = True + output = _ANSI_ESCAPE.sub("", completed.stdout or "") + match = _SERVICE_URL.search(output) + if match is None: + raise JupyterSandboxError( + "OpenShell exposed the service but did not return its URL" + ) + + service_url = match.group(1).rstrip("/") + parsed = urlsplit(service_url) + host = parsed.hostname or "" + local_host = host in {"127.0.0.1", "::1", "localhost"} or host.endswith( + ".localhost" + ) + if parsed.scheme not in {"http", "https"} or not local_host: + raise JupyterSandboxError( + "this example supports services exposed by a local gateway only" + ) + return service_url + + def _wait_until_ready(self) -> None: + deadline = time.monotonic() + self.ready_timeout + while time.monotonic() < deadline: + try: + self._request("GET", "/api/status") + return + except (HTTPError, URLError, TimeoutError, JupyterSandboxError): + time.sleep(1) + + logs = self._jupyter_logs() + detail = f"\nJupyter log:\n{logs}" if logs else "" + raise JupyterSandboxError( + f"Jupyter in sandbox {self.name} was not ready after " + f"{self.ready_timeout:g} seconds{detail}" + ) + + def _jupyter_logs(self) -> str: + try: + result = self._exec( + [ + "/bin/sh", + "-c", + "tail -n 40 /tmp/openshell-jupyter.log 2>/dev/null || true", + ] + ) + except JupyterSandboxError: + return "" + return self._redact(result.stdout).strip() + + def _execute_on_kernel(self, kernel_id: str, code: str) -> str: + try: + websocket = importlib.import_module("websocket") + except ImportError as error: + raise JupyterSandboxError( + "websocket-client is required; install the example dependencies" + ) from error + + session_id = uuid.uuid4().hex + msg_id = uuid.uuid4().hex + channels_url = self._api_url(f"/api/kernels/{kernel_id}/channels") + channels_url = self._with_query(channels_url, session_id=session_id) + parsed = urlsplit(channels_url) + host = parsed.hostname or "localhost" + ws_url = urlunsplit( + ( + "wss" if parsed.scheme == "https" else "ws", + parsed.netloc, + parsed.path, + parsed.query, + parsed.fragment, + ) + ) + + try: + socket = websocket.create_connection( + ws_url, + http_no_proxy=[host], + suppress_origin=True, + timeout=min(5.0, self.execute_timeout), + ) + except BaseException as error: + raise JupyterSandboxError( + self._safe_message("could not open Jupyter kernel channels", error) + ) from None + + request = { + "channel": "shell", + "header": { + "date": datetime.now(UTC).isoformat(), + "msg_id": msg_id, + "msg_type": "execute_request", + "session": session_id, + "username": "openshell", + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "allow_stdin": False, + "code": code, + "silent": False, + "stop_on_error": True, + "store_history": True, + "user_expressions": {}, + }, + } + + output: list[str] = [] + error_text: str | None = None + idle = False + replied = False + deadline = time.monotonic() + self.execute_timeout + try: + socket.send(json.dumps(request)) + while not (idle and replied): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JupyterSandboxError( + f"execution timed out after {self.execute_timeout:g} seconds" + ) + socket.settimeout(min(1.0, remaining)) + try: + message = self._decode_message(socket.recv()) + except websocket.WebSocketTimeoutException: + continue + + parent = message.get("parent_header", {}) + if parent.get("msg_id") != msg_id: + continue + header = message.get("header", {}) + msg_type = header.get("msg_type") + content = message.get("content", {}) + + if msg_type == "stream": + output.append(str(content.get("text", ""))) + elif msg_type in {"display_data", "execute_result"}: + data = content.get("data", {}) + if "text/plain" in data: + output.append(str(data["text/plain"])) + elif msg_type == "error": + error_text = self._format_kernel_error(content) + elif msg_type == "execute_reply": + replied = True + if content.get("status") == "error" and error_text is None: + error_text = self._format_kernel_error(content) + elif msg_type == "status" and content.get("execution_state") == "idle": + idle = True + except JupyterSandboxError: + raise + except BaseException as error: + raise JupyterSandboxError( + self._safe_message("Jupyter kernel communication failed", error) + ) from None + finally: + socket.close() + + if error_text is not None: + raise JupyterExecutionError(error_text) + return "".join(output) + + @staticmethod + def _decode_message(payload: str | bytes) -> dict[str, Any]: + if isinstance(payload, str): + decoded = json.loads(payload) + else: + if len(payload) < 8: + raise JupyterSandboxError("received an invalid binary Jupyter message") + offset_count = struct.unpack_from("!I", payload)[0] + table_size = 4 * (offset_count + 1) + if offset_count < 1 or len(payload) < table_size: + raise JupyterSandboxError("received an invalid binary Jupyter message") + offsets = struct.unpack_from(f"!{offset_count}I", payload, 4) + start = offsets[0] + end = offsets[1] if offset_count > 1 else len(payload) + if start < table_size or end < start or end > len(payload): + raise JupyterSandboxError("received an invalid binary Jupyter message") + decoded = json.loads(payload[start:end]) + if not isinstance(decoded, dict): + raise JupyterSandboxError("received an invalid Jupyter message") + return decoded + + @staticmethod + def _format_kernel_error(content: dict[str, Any]) -> str: + traceback = content.get("traceback") + if isinstance(traceback, list) and traceback: + return _ANSI_ESCAPE.sub("", "\n".join(map(str, traceback))) + name = str(content.get("ename", "Error")) + value = str(content.get("evalue", "")) + return f"{name}: {value}".rstrip() + + def _request_json( + self, method: str, path: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + body = self._request(method, path, payload) + decoded = json.loads(body) + if not isinstance(decoded, dict): + raise JupyterSandboxError("Jupyter returned an unexpected response") + return decoded + + def _request( + self, method: str, path: str, payload: dict[str, Any] | None = None + ) -> bytes: + data = json.dumps(payload).encode() if payload is not None else None + request = Request( + self._api_url(path), + data=data, + headers={"Content-Type": "application/json"}, + method=method, + ) + try: + with build_opener(ProxyHandler({})).open(request, timeout=5) as response: + return response.read() + except HTTPError as error: + detail = self._redact(error.read().decode(errors="replace")).strip() + suffix = f": {detail}" if detail else "" + raise JupyterSandboxError( + f"Jupyter API {method} {path} returned HTTP {error.code}{suffix}" + ) from None + except URLError as error: + raise JupyterSandboxError( + self._safe_message(f"Jupyter API {method} {path} failed", error) + ) from None + + def _api_url(self, path: str) -> str: + if self.service_url is None: + raise RuntimeError("sandbox is only available inside the context") + base = self.service_url.rstrip("/") + return self._with_query(f"{base}/{path.lstrip('/')}") + + def _with_query(self, url: str, **extra: str) -> str: + parsed = urlsplit(url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query["token"] = self._token + query.update(extra) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(query), + parsed.fragment, + ) + ) + + def _cleanup(self) -> list[BaseException]: + errors: list[BaseException] = [] + if self._service_created: + try: + self._run_service( + "service", + "delete", + self.name, + JUPYTER_SERVICE, + capture=True, + ) + except BaseException as error: + errors.append(error) + finally: + self._service_created = False + self.service_url = None + + if self._sandbox_create_attempted or self._sandbox_created: + try: + deleted = self.client.delete(self.name) + if deleted: + self.client.wait_deleted( + self.name, timeout_seconds=self.ready_timeout + ) + except BaseException as error: + if "not found" not in str(error).lower(): + errors.append(error) + finally: + self._session = None + self._sandbox_create_attempted = False + self._sandbox_created = False + return errors + + @staticmethod + def _attach_cleanup_errors( + original: BaseException, cleanup_errors: list[BaseException] + ) -> None: + for error in cleanup_errors: + note = f"cleanup also failed: {JupyterSandbox._error_text(error)}" + if hasattr(original, "add_note"): + original.add_note(note) + warnings.warn(note, stacklevel=3) + + def _exec( + self, + command: list[str], + *, + stdin: bytes | None = None, + ) -> ExecResult: + if self._session is None: + raise RuntimeError("sandbox is only available inside the context") + result = self._session.exec( + command, + stdin=stdin, + timeout_seconds=max(1, round(self.ready_timeout)), + ) + if result.exit_code != 0: + detail = self._redact(result.stderr or result.stdout).strip() + suffix = f": {detail}" if detail else "" + raise JupyterSandboxError( + f"sandbox command failed ({result.exit_code}): " + f"{' '.join(command)}{suffix}" + ) + return result + + def _run_service( + self, + *args: str, + capture: bool = False, + ) -> subprocess.CompletedProcess[str]: + command = [self.openshell_bin, *args] + environment = os.environ.copy() + if self.cluster is not None: + environment["OPENSHELL_GATEWAY"] = self.cluster + completed = subprocess.run( + command, + check=False, + env=environment, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + if completed.returncode != 0: + detail = self._redact(completed.stderr or completed.stdout or "").strip() + suffix = f": {detail}" if detail else "" + raise JupyterSandboxError( + f"OpenShell command failed ({completed.returncode}): " + f"{' '.join(command)}{suffix}" + ) + return completed + + def _redact(self, value: str) -> str: + value = value.replace(self._token, "") + return re.sub(r"([?&]token=)[^&\s]+", r"\1", value) + + def _safe_message(self, prefix: str, error: BaseException) -> str: + return f"{prefix}: {self._redact(self._error_text(error))}" + + @staticmethod + def _error_text(error: BaseException) -> str: + return str(error) or type(error).__name__ diff --git a/examples/jupyter-sandbox/policy.yaml b/examples/jupyter-sandbox/policy.yaml new file mode 100644 index 0000000000..67db855e1c --- /dev/null +++ b/examples/jupyter-sandbox/policy.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +# The Jupyter service is exposed inbound through the gateway. The notebook +# does not need outbound network access for this demonstration. +network_policies: {} From 7748ad8392767b3b94171fd958c0c28332f43532 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 16 Jul 2026 17:22:45 -0700 Subject: [PATCH 2/8] refactor(examples): simplify Jupyter sandbox demo Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/README.md | 130 +++++++++----------- examples/jupyter-sandbox/demo.py | 38 +++--- examples/jupyter-sandbox/fleet.py | 74 ----------- examples/jupyter-sandbox/jupyter_sandbox.py | 2 +- 4 files changed, 78 insertions(+), 166 deletions(-) delete mode 100644 examples/jupyter-sandbox/fleet.py diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index 723bd49f79..719cecb32b 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -1,32 +1,27 @@ -# Jupyter Sandbox Fleet +# Jupyter Sandbox -Launch several OpenShell sandboxes, expose each Jupyter API server as a named -OpenShell service, and submit Python code to one member through Jupyter's REST -and kernel WebSocket APIs. +Launch one OpenShell sandbox, expose its Jupyter API server as a named service, +and submit Python code to a kernel through that service. -The example keeps the fleet abstraction separate from the sandbox type: -`Fleet[T]` can compose any context-managed resource, while `JupyterSandbox` -owns one sandbox's OpenShell, Jupyter, service, and cleanup lifecycle. It uses -the OpenShell Python SDK for gateway health, sandbox creation, readiness, -command execution, and deletion. +The example uses the OpenShell Python SDK for gateway health, sandbox creation, +readiness, command execution, and deletion. It uses the `openshell` CLI only for +service expose and delete operations that are not yet available in the SDK. ## Prerequisites - A running local OpenShell gateway (`mise run gateway:docker` for development) -- The `openshell` CLI configured to use that gateway for the temporary service - expose/delete fallback +- The `openshell` CLI configured to use that gateway - Docker to build the example image -- Python 3.11 or later with `openshell`, `pyyaml`, and `websocket-client` +- Python 3.11 or later - [uv](https://docs.astral.sh/uv/) to install the Python dependencies -The example intentionally supports a local gateway only. Remote service access -requires handling the gateway's authentication boundary and is outside this -example's scope. +The example supports a local gateway only. Remote service access requires +handling the gateway authentication boundary and is outside this example's +scope. -## Launch three sandboxes +## Run the example -Install the Python dependencies, build the Jupyter-ready image once, and run -the script: +Install the Python dependencies, build the Jupyter image, and run the script: ```shell cd examples/jupyter-sandbox @@ -37,80 +32,77 @@ docker build -t openshell-jupyter-sandbox:local . python demo.py ``` -The demo performs these operations: +The script: -1. Verifies the selected gateway through `SandboxClient.health()`. -2. Creates three sandboxes from the configured image and policy through the - Python SDK. -3. Starts Jupyter Server on `127.0.0.1:8888` in each sandbox. -4. Exposes every server as an OpenShell service named `jupyter`. -5. Creates a kernel in the first sandbox and executes this code over the - Jupyter API: +1. Creates one sandbox from the configured image and policy through the Python + SDK. +2. Starts a token-authenticated Jupyter Server on `127.0.0.1:8888` in the + sandbox. +3. Exposes the server as an OpenShell service named `jupyter` and prints the + service URL. +4. Creates a Python kernel with `POST /api/kernels` through the service. +5. Connects to `/api/kernels/{kernel_id}/channels` through the service and sends + a Jupyter `execute_request` over WebSocket. +6. Prints the kernel output, then deletes the kernel, service, and sandbox. - ```python - print(sum(i * i for i in range(10))) - ``` +The submitted code is: -6. Deletes every service and sandbox when the context exits, including after - an exception. Sandbox deletion uses the Python SDK. +```python +print(sum(i * i for i in range(10))) +``` The expected result is `285`. -## Configure the fleet +## Submit code through the service + +`JupyterSandbox` owns the sandbox, Jupyter server, exposed service, and cleanup +lifecycle. Call `execute()` inside its context to create a kernel and submit +code through the exposed service: + +```python +with JupyterSandbox( + client=client, + name="jupyter-example", + image="openshell-jupyter-sandbox:local", + policy="policy.yaml", +) as sandbox: + print(f"Jupyter service: {sandbox.service_url}") + output = sandbox.execute("print('hello from Jupyter')") + print(output) +``` + +Each sandbox gets a unique Jupyter token. The token travels to the sandbox over +standard input, is stored in a mode-restricted file, and is attached internally +to the REST and WebSocket requests. The example prints the service URL without +the token. + +## Configure the sandbox -Edit the configuration constants at the top of `demo.py` to select the sandbox -image, fleet size, policy, names, gateway, and work: +Edit the constants at the top of `demo.py` to select the image, policy, sandbox +name prefix, gateway, and code: ```python IMAGE = "registry.example.com/jupyter-sandbox:latest" -SANDBOX_COUNT = 5 POLICY = EXAMPLE_DIR / "my-policy.yaml" NAME_PREFIX = "analysis" CODE = 'print("hello from Jupyter")' GATEWAY = None ``` -`IMAGE` accepts an OCI image reference. The Python SDK does not currently -build a Dockerfile the way `openshell sandbox create --from` does, so build or -publish the image first. A custom image must provide `jupyter server` and a -`python3` kernel. It must also contain a non-root `sandbox` user and group -because the included policy selects that process identity. The included -Dockerfile uses `python:3.13-slim`, creates that identity, and installs pinned -Jupyter dependencies. +`IMAGE` accepts an OCI image reference. The Python SDK does not currently build +a Dockerfile the way `openshell sandbox create --from` does, so build or publish +the image first. A custom image must provide `jupyter server`, a `python3` +kernel, and the non-root `sandbox` user and group selected by the policy. `policy.yaml` allows the system paths Jupyter needs and writable access to `/sandbox` and `/tmp`. Its empty `network_policies` map denies outbound network access. Exposing the loopback Jupyter server through OpenShell is inbound and -does not require an egress rule. Replace the policy when notebook work needs -explicit outbound destinations. - -## Reuse the abstraction - -The core composition is deliberately small: - -```python -with Fleet( - count=3, - factory=lambda index: JupyterSandbox( - client=client, - name=f"jupyter-{run_id}-{index + 1}", - image=image, - policy=policy, - ), -) as fleet: - print(fleet[0].execute("print(sum(i * i for i in range(10)))")) -``` - -Each sandbox gets a unique Jupyter token. The token travels to the sandbox over -standard input, is stored in a mode-restricted file, and is attached internally -to Jupyter API requests. The example never prints token-bearing URLs. OpenShell -service URLs shown by the demo are safe to display but still require the -process-local token for Jupyter API access. +does not require an egress rule. ## Proposed Python SDK APIs -This example still has deliberate seams because the SDK does not expose -all of the CLI's functionality: +The example still has deliberate seams because the SDK does not expose all CLI +functionality: - Add `SandboxClient.expose_service()`, `get_service()`, `list_services()`, and `delete_service()`, returning a public `ServiceEndpoint` model. The example diff --git a/examples/jupyter-sandbox/demo.py b/examples/jupyter-sandbox/demo.py index 5ab77e5290..7ab247364e 100644 --- a/examples/jupyter-sandbox/demo.py +++ b/examples/jupyter-sandbox/demo.py @@ -1,23 +1,21 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Launch a fleet of Jupyter sandboxes and submit code to the first member.""" +"""Launch one Jupyter sandbox and submit code to its exposed kernel.""" from __future__ import annotations import secrets from pathlib import Path -from fleet import Fleet from jupyter_sandbox import JupyterSandbox from openshell import SandboxClient EXAMPLE_DIR = Path(__file__).resolve().parent -# Configure the fleet here. +# Configure the sandbox and the work submitted to its Jupyter kernel here. IMAGE = "openshell-jupyter-sandbox:local" -SANDBOX_COUNT = 3 POLICY = EXAMPLE_DIR / "policy.yaml" NAME_PREFIX = "jupyter" CODE = "print(sum(i * i for i in range(10)))" @@ -30,25 +28,21 @@ def main() -> None: health = client.health() print(f"Connected to OpenShell {health.version}") - run_id = secrets.token_hex(3) - with Fleet( - count=SANDBOX_COUNT, - factory=lambda index: JupyterSandbox( - client=client, - name=f"{NAME_PREFIX}-{run_id}-{index + 1}", - image=IMAGE, - policy=POLICY, - openshell_bin=OPENSHELL_BIN, - cluster=GATEWAY, - ), - ) as fleet: - print(f"\nStarted {len(fleet)} Jupyter sandboxes:") - for sandbox in fleet: - print(f" {sandbox.name}: {sandbox.service_url}") - - print(f"\nSubmitting code to {fleet[0].name} over the Jupyter API:") + sandbox_name = f"{NAME_PREFIX}-{secrets.token_hex(3)}" + with JupyterSandbox( + client=client, + name=sandbox_name, + image=IMAGE, + policy=POLICY, + openshell_bin=OPENSHELL_BIN, + cluster=GATEWAY, + ) as sandbox: + print(f"\nStarted sandbox {sandbox.name}") + print(f"Jupyter service: {sandbox.service_url}") + + print("\nCreating a kernel and submitting code through the service:") print(CODE) - result = fleet[0].execute(CODE) + result = sandbox.execute(CODE) print("\nResult:") print(result, end="" if result.endswith("\n") else "\n") diff --git a/examples/jupyter-sandbox/fleet.py b/examples/jupyter-sandbox/fleet.py deleted file mode 100644 index cd18fb6389..0000000000 --- a/examples/jupyter-sandbox/fleet.py +++ /dev/null @@ -1,74 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""A small, generic abstraction for managing a fleet of context managers.""" - -from __future__ import annotations - -import sys -from collections.abc import Callable, Iterator, Sequence -from contextlib import AbstractContextManager, ExitStack -from typing import Generic, TypeVar, overload - -T = TypeVar("T") -MemberFactory = Callable[[int], AbstractContextManager[T]] - - -class Fleet(Sequence[T], AbstractContextManager["Fleet[T]"], Generic[T]): - """Create and clean up a fixed number of context-managed members.""" - - def __init__(self, *, count: int, factory: MemberFactory[T]) -> None: - if count < 1: - raise ValueError("count must be at least 1") - self._count = count - self._factory = factory - self._members: list[T] = [] - self._stack: ExitStack | None = None - - def __enter__(self) -> Fleet[T]: - if self._stack is not None: - raise RuntimeError("fleet is already running") - - stack = ExitStack() - stack.__enter__() - self._stack = stack - try: - for index in range(self._count): - self._members.append(stack.enter_context(self._factory(index))) - except BaseException: - self._members.clear() - self._stack = None - stack.__exit__(*sys.exc_info()) - raise - return self - - def __exit__(self, exc_type, exc_value, traceback) -> bool | None: - stack = self._stack - if stack is None: - raise RuntimeError("fleet is not running") - - try: - return stack.__exit__(exc_type, exc_value, traceback) - finally: - self._members.clear() - self._stack = None - - def _running_members(self) -> list[T]: - if self._stack is None: - raise RuntimeError("fleet members are only available inside the context") - return self._members - - @overload - def __getitem__(self, index: int) -> T: ... - - @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... - - def __getitem__(self, index: int | slice) -> T | Sequence[T]: - return self._running_members()[index] - - def __len__(self) -> int: - return len(self._running_members()) - - def __iter__(self) -> Iterator[T]: - return iter(self._running_members()) diff --git a/examples/jupyter-sandbox/jupyter_sandbox.py b/examples/jupyter-sandbox/jupyter_sandbox.py index 7ed406e27b..739dee2843 100644 --- a/examples/jupyter-sandbox/jupyter_sandbox.py +++ b/examples/jupyter-sandbox/jupyter_sandbox.py @@ -96,7 +96,7 @@ def __init__( if Path(image_reference).expanduser().exists(): raise ValueError( "the Python SDK requires an OCI image reference; build the " - "Dockerfile before launching the fleet" + "Dockerfile before launching the sandbox" ) self.client = client From 9e291d1f55cbdd301483f56f98efca43372d875b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 12:39:47 -0700 Subject: [PATCH 3/8] fix(examples): refresh Jupyter sandbox for current SDK Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/README.md | 28 ++-- examples/jupyter-sandbox/demo.py | 5 +- examples/jupyter-sandbox/jupyter_sandbox.py | 125 +++++------------- .../jupyter-sandbox/test_jupyter_sandbox.py | 90 +++++++++++++ 4 files changed, 142 insertions(+), 106 deletions(-) create mode 100644 examples/jupyter-sandbox/test_jupyter_sandbox.py diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index 719cecb32b..308f097716 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -4,13 +4,12 @@ Launch one OpenShell sandbox, expose its Jupyter API server as a named service, and submit Python code to a kernel through that service. The example uses the OpenShell Python SDK for gateway health, sandbox creation, -readiness, command execution, and deletion. It uses the `openshell` CLI only for -service expose and delete operations that are not yet available in the SDK. +service exposure, readiness, command execution, and deletion. ## Prerequisites - A running local OpenShell gateway (`mise run gateway:docker` for development) -- The `openshell` CLI configured to use that gateway +- OpenShell gateway configuration for the Python SDK - Docker to build the example image - Python 3.11 or later - [uv](https://docs.astral.sh/uv/) to install the Python dependencies @@ -27,7 +26,7 @@ Install the Python dependencies, build the Jupyter image, and run the script: cd examples/jupyter-sandbox uv venv source .venv/bin/activate -uv pip install openshell pyyaml websocket-client +uv pip install -e ../.. pyyaml websocket-client docker build -t openshell-jupyter-sandbox:local . python demo.py ``` @@ -38,12 +37,13 @@ The script: SDK. 2. Starts a token-authenticated Jupyter Server on `127.0.0.1:8888` in the sandbox. -3. Exposes the server as an OpenShell service named `jupyter` and prints the - service URL. +3. Exposes the server as an OpenShell service named `jupyter` during sandbox + creation and prints the service URL. 4. Creates a Python kernel with `POST /api/kernels` through the service. 5. Connects to `/api/kernels/{kernel_id}/channels` through the service and sends a Jupyter `execute_request` over WebSocket. -6. Prints the kernel output, then deletes the kernel, service, and sandbox. +6. Prints the kernel output, then deletes the kernel and sandbox. Sandbox + deletion also removes its service. The submitted code is: @@ -63,6 +63,7 @@ code through the exposed service: with JupyterSandbox( client=client, name="jupyter-example", + workspace="default", image="openshell-jupyter-sandbox:local", policy="policy.yaml", ) as sandbox: @@ -79,12 +80,13 @@ the token. ## Configure the sandbox Edit the constants at the top of `demo.py` to select the image, policy, sandbox -name prefix, gateway, and code: +name prefix, workspace, gateway, and code: ```python IMAGE = "registry.example.com/jupyter-sandbox:latest" POLICY = EXAMPLE_DIR / "my-policy.yaml" NAME_PREFIX = "analysis" +WORKSPACE = "default" CODE = 'print("hello from Jupyter")' GATEWAY = None ``` @@ -104,9 +106,9 @@ does not require an egress rule. The example still has deliberate seams because the SDK does not expose all CLI functionality: -- Add `SandboxClient.expose_service()`, `get_service()`, `list_services()`, and - `delete_service()`, returning a public `ServiceEndpoint` model. The example - currently uses the `openshell service` CLI only for expose and delete. +- The SDK can expose a service during sandbox creation. Public methods for + adding, querying, and deleting services after creation would support a + longer-lived sandbox workflow. - Add a public sandbox configuration builder that accepts `image` and a `SandboxPolicy`. The low-level client currently requires generated protobuf types from the private `openshell._proto` package. @@ -117,5 +119,5 @@ functionality: and build directories. Until then, Python SDK callers must prepare the OCI image before creating a sandbox. -With those APIs, `JupyterSandbox` could remove its CLI subprocess adapter and -all imports from `openshell._proto`. +With the remaining configuration APIs, `JupyterSandbox` could remove its +imports from `openshell._proto`. diff --git a/examples/jupyter-sandbox/demo.py b/examples/jupyter-sandbox/demo.py index 7ab247364e..59f940dc7f 100644 --- a/examples/jupyter-sandbox/demo.py +++ b/examples/jupyter-sandbox/demo.py @@ -18,9 +18,9 @@ IMAGE = "openshell-jupyter-sandbox:local" POLICY = EXAMPLE_DIR / "policy.yaml" NAME_PREFIX = "jupyter" +WORKSPACE = "default" CODE = "print(sum(i * i for i in range(10)))" GATEWAY: str | None = None # None selects the active OpenShell gateway. -OPENSHELL_BIN = "openshell" # Used only for service APIs missing from the SDK. def main() -> None: @@ -32,10 +32,9 @@ def main() -> None: with JupyterSandbox( client=client, name=sandbox_name, + workspace=WORKSPACE, image=IMAGE, policy=POLICY, - openshell_bin=OPENSHELL_BIN, - cluster=GATEWAY, ) as sandbox: print(f"\nStarted sandbox {sandbox.name}") print(f"Jupyter service: {sandbox.service_url}") diff --git a/examples/jupyter-sandbox/jupyter_sandbox.py b/examples/jupyter-sandbox/jupyter_sandbox.py index 739dee2843..5742a7fc91 100644 --- a/examples/jupyter-sandbox/jupyter_sandbox.py +++ b/examples/jupyter-sandbox/jupyter_sandbox.py @@ -7,11 +7,9 @@ import importlib import json -import os import re import secrets import struct -import subprocess import time import uuid import warnings @@ -26,6 +24,7 @@ import yaml from google.protobuf.json_format import ParseDict, ParseError +from openshell import ServiceExposure from openshell._proto import openshell_pb2, sandbox_pb2 if TYPE_CHECKING: @@ -35,7 +34,6 @@ JUPYTER_SERVICE = "jupyter" _ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") _NAME = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") -_SERVICE_URL = re.compile(r"^\s*URL:\s+(\S+)\s*$", re.MULTILINE) _START_JUPYTER = r""" set -eu umask 077 @@ -74,10 +72,9 @@ def __init__( *, client: SandboxClient, name: str, + workspace: str, image: str | Path, policy: str | Path, - openshell_bin: str = "openshell", - cluster: str | None = None, ready_timeout: float = 60.0, execute_timeout: float = 60.0, ) -> None: @@ -87,6 +84,8 @@ def __init__( ) if ready_timeout <= 0 or execute_timeout <= 0: raise ValueError("timeouts must be greater than zero") + if not workspace: + raise ValueError("workspace must be non-empty") policy_path = Path(policy).expanduser().resolve() if not policy_path.is_file(): @@ -101,25 +100,20 @@ def __init__( self.client = client self.name = name + self.workspace = workspace self.image = image_reference self.policy = policy_path - self.openshell_bin = openshell_bin - self.cluster = cluster self.ready_timeout = ready_timeout self.execute_timeout = execute_timeout self.service_url: str | None = None self._token = secrets.token_urlsafe(32) self._session: SandboxSession | None = None - self._sandbox_create_attempted = False - self._sandbox_created = False - self._service_created = False def __enter__(self) -> JupyterSandbox: try: self._create_sandbox() self._start_jupyter() - self.service_url = self._expose_service() self._wait_until_ready() except BaseException as error: cleanup_errors = self._cleanup() @@ -168,14 +162,33 @@ def execute(self, code: str) -> str: ) def _create_sandbox(self) -> None: - self._sandbox_create_attempted = True self._session = self.client.create_session( + workspace=self.workspace, name=self.name, labels={"app": "jupyter", "managed-by": "jupyter-sandbox-example"}, spec=self._sandbox_spec(), + service_exposures=[ + ServiceExposure(service=JUPYTER_SERVICE, target_port=JUPYTER_PORT) + ], + ) + service_url = self._session.sandbox.service_urls.get(JUPYTER_SERVICE, "") + if not service_url: + raise JupyterSandboxError( + "OpenShell did not return the Jupyter service URL" + ) + parsed = urlsplit(service_url) + host = parsed.hostname or "" + local_host = host in {"127.0.0.1", "::1", "localhost"} or host.endswith( + ".localhost" + ) + if parsed.scheme not in {"http", "https"} or not local_host: + raise JupyterSandboxError( + "this example supports services exposed by a local gateway only" + ) + self.service_url = service_url.rstrip("/") + self.client.wait_ready( + self.name, workspace=self.workspace, timeout_seconds=self.ready_timeout ) - self._sandbox_created = True - self.client.wait_ready(self.name, timeout_seconds=self.ready_timeout) def _start_jupyter(self) -> None: self._exec( @@ -213,35 +226,6 @@ def _sandbox_spec(self) -> openshell_pb2.SandboxSpec: providers=[], ) - def _expose_service(self) -> str: - completed = self._run_service( - "service", - "expose", - self.name, - str(JUPYTER_PORT), - JUPYTER_SERVICE, - capture=True, - ) - self._service_created = True - output = _ANSI_ESCAPE.sub("", completed.stdout or "") - match = _SERVICE_URL.search(output) - if match is None: - raise JupyterSandboxError( - "OpenShell exposed the service but did not return its URL" - ) - - service_url = match.group(1).rstrip("/") - parsed = urlsplit(service_url) - host = parsed.hostname or "" - local_host = host in {"127.0.0.1", "::1", "localhost"} or host.endswith( - ".localhost" - ) - if parsed.scheme not in {"http", "https"} or not local_host: - raise JupyterSandboxError( - "this example supports services exposed by a local gateway only" - ) - return service_url - def _wait_until_ready(self) -> None: deadline = time.monotonic() + self.ready_timeout while time.monotonic() < deadline: @@ -468,35 +452,22 @@ def _with_query(self, url: str, **extra: str) -> str: def _cleanup(self) -> list[BaseException]: errors: list[BaseException] = [] - if self._service_created: - try: - self._run_service( - "service", - "delete", - self.name, - JUPYTER_SERVICE, - capture=True, - ) - except BaseException as error: - errors.append(error) - finally: - self._service_created = False - self.service_url = None - - if self._sandbox_create_attempted or self._sandbox_created: + if self._session is not None: try: - deleted = self.client.delete(self.name) - if deleted: + deletion = self._session.delete(allow_missing=True) + if deletion.sandbox_id: self.client.wait_deleted( - self.name, timeout_seconds=self.ready_timeout + self.name, + workspace=self.workspace, + timeout_seconds=self.ready_timeout, + expected_sandbox_id=deletion.sandbox_id, ) except BaseException as error: if "not found" not in str(error).lower(): errors.append(error) finally: self._session = None - self._sandbox_create_attempted = False - self._sandbox_created = False + self.service_url = None return errors @staticmethod @@ -531,32 +502,6 @@ def _exec( ) return result - def _run_service( - self, - *args: str, - capture: bool = False, - ) -> subprocess.CompletedProcess[str]: - command = [self.openshell_bin, *args] - environment = os.environ.copy() - if self.cluster is not None: - environment["OPENSHELL_GATEWAY"] = self.cluster - completed = subprocess.run( - command, - check=False, - env=environment, - text=True, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None, - ) - if completed.returncode != 0: - detail = self._redact(completed.stderr or completed.stdout or "").strip() - suffix = f": {detail}" if detail else "" - raise JupyterSandboxError( - f"OpenShell command failed ({completed.returncode}): " - f"{' '.join(command)}{suffix}" - ) - return completed - def _redact(self, value: str) -> str: value = value.replace(self._token, "") return re.sub(r"([?&]token=)[^&\s]+", r"\1", value) diff --git a/examples/jupyter-sandbox/test_jupyter_sandbox.py b/examples/jupyter-sandbox/test_jupyter_sandbox.py new file mode 100644 index 0000000000..05546ae7c4 --- /dev/null +++ b/examples/jupyter-sandbox/test_jupyter_sandbox.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Lifecycle checks for the SDK-backed Jupyter example.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast + +import pytest +from jupyter_sandbox import JupyterSandbox, JupyterSandboxError + +if TYPE_CHECKING: + from openshell import SandboxClient + + +class FakeSession: + def __init__(self, service_url: str | None) -> None: + self.sandbox = SimpleNamespace( + service_urls={"jupyter": service_url} if service_url else {} + ) + self.deleted = False + + def delete(self, *, allow_missing: bool = False) -> SimpleNamespace: + assert allow_missing + self.deleted = True + return SimpleNamespace(sandbox_id="sandbox-id") + + +class FakeClient: + def __init__(self, service_url: str | None) -> None: + self.session = FakeSession(service_url) + self.create_args: dict[str, Any] | None = None + self.waited_for_deletion: dict[str, Any] | None = None + + def create_session(self, **kwargs: Any) -> FakeSession: + self.create_args = kwargs + return self.session + + def wait_ready(self, name: str, **kwargs: Any) -> None: + assert name == "jupyter-test" + assert kwargs["workspace"] == "default" + + def wait_deleted(self, name: str, **kwargs: Any) -> None: + assert name == "jupyter-test" + self.waited_for_deletion = kwargs + + +def test_exposes_service_and_cleans_up_with_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = FakeClient("http://jupyter.openshell.localhost:17670/") + monkeypatch.setattr(JupyterSandbox, "_start_jupyter", lambda _self: None) + monkeypatch.setattr(JupyterSandbox, "_wait_until_ready", lambda _self: None) + + with JupyterSandbox( + client=cast("SandboxClient", client), + name="jupyter-test", + workspace="default", + image="jupyter:local", + policy=Path(__file__).with_name("policy.yaml"), + ) as sandbox: + assert sandbox.service_url == "http://jupyter.openshell.localhost:17670" + assert client.create_args is not None + exposure = client.create_args["service_exposures"][0] + assert (exposure.service, exposure.target_port) == ("jupyter", 8888) + + assert client.session.deleted + assert client.waited_for_deletion is not None + assert client.waited_for_deletion["expected_sandbox_id"] == "sandbox-id" + + +def test_missing_service_url_still_deletes_created_sandbox() -> None: + client = FakeClient(None) + + with ( + pytest.raises(JupyterSandboxError, match="service URL"), + JupyterSandbox( + client=cast("SandboxClient", client), + name="jupyter-test", + workspace="default", + image="jupyter:local", + policy=Path(__file__).with_name("policy.yaml"), + ), + ): + pytest.fail("context should not have started") + + assert client.session.deleted From 767c5003616cebb5384b348e72d1a0567bc2b44a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 12:56:29 -0700 Subject: [PATCH 4/8] refactor(examples): simplify Jupyter sandbox demo Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/README.md | 121 +---- examples/jupyter-sandbox/demo.py | 218 +++++++- examples/jupyter-sandbox/jupyter_sandbox.py | 514 ------------------ examples/jupyter-sandbox/test_demo.py | 78 +++ .../jupyter-sandbox/test_jupyter_sandbox.py | 90 --- 5 files changed, 293 insertions(+), 728 deletions(-) delete mode 100644 examples/jupyter-sandbox/jupyter_sandbox.py create mode 100644 examples/jupyter-sandbox/test_demo.py delete mode 100644 examples/jupyter-sandbox/test_jupyter_sandbox.py diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index 308f097716..f603042d15 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -1,29 +1,19 @@ # Jupyter Sandbox -Launch one OpenShell sandbox, expose its Jupyter API server as a named service, -and submit Python code to a kernel through that service. - -The example uses the OpenShell Python SDK for gateway health, sandbox creation, -service exposure, readiness, command execution, and deletion. +Launch a Jupyter Server in one OpenShell sandbox, open its local service URL in +your browser, and submit code to a kernel through the exposed service. ## Prerequisites -- A running local OpenShell gateway (`mise run gateway:docker` for development) -- OpenShell gateway configuration for the Python SDK -- Docker to build the example image -- Python 3.11 or later -- [uv](https://docs.astral.sh/uv/) to install the Python dependencies - -The example supports a local gateway only. Remote service access requires -handling the gateway authentication boundary and is outside this example's -scope. +- A working local OpenShell gateway and its gateway configuration +- Docker to build the Jupyter image +- Python 3.11 or later and [uv](https://docs.astral.sh/uv/) -## Run the example +## Run -Install the Python dependencies, build the Jupyter image, and run the script: +From this directory: ```shell -cd examples/jupyter-sandbox uv venv source .venv/bin/activate uv pip install -e ../.. pyyaml websocket-client @@ -31,93 +21,26 @@ docker build -t openshell-jupyter-sandbox:local . python demo.py ``` -The script: - -1. Creates one sandbox from the configured image and policy through the Python - SDK. -2. Starts a token-authenticated Jupyter Server on `127.0.0.1:8888` in the - sandbox. -3. Exposes the server as an OpenShell service named `jupyter` during sandbox - creation and prints the service URL. -4. Creates a Python kernel with `POST /api/kernels` through the service. -5. Connects to `/api/kernels/{kernel_id}/channels` through the service and sends - a Jupyter `execute_request` over WebSocket. -6. Prints the kernel output, then deletes the kernel and sandbox. Sandbox - deletion also removes its service. +The script creates a sandbox and exposes port 8888 as the `jupyter` service. It +starts a token-authenticated Jupyter Server on the sandbox's loopback address, +then prints a URL to open in your local browser. Jupyter may take a few seconds +to start. Keep the script running while you use it; press Enter or Ctrl-C to +delete the sandbox and its service. -The submitted code is: +The script also creates a Jupyter kernel through the exposed REST API, sends +this code through the kernel's WebSocket channel, and prints `285`: ```python print(sum(i * i for i in range(10))) ``` -The expected result is `285`. - -## Submit code through the service - -`JupyterSandbox` owns the sandbox, Jupyter server, exposed service, and cleanup -lifecycle. Call `execute()` inside its context to create a kernel and submit -code through the exposed service: - -```python -with JupyterSandbox( - client=client, - name="jupyter-example", - workspace="default", - image="openshell-jupyter-sandbox:local", - policy="policy.yaml", -) as sandbox: - print(f"Jupyter service: {sandbox.service_url}") - output = sandbox.execute("print('hello from Jupyter')") - print(output) -``` - -Each sandbox gets a unique Jupyter token. The token travels to the sandbox over -standard input, is stored in a mode-restricted file, and is attached internally -to the REST and WebSocket requests. The example prints the service URL without -the token. - -## Configure the sandbox - -Edit the constants at the top of `demo.py` to select the image, policy, sandbox -name prefix, workspace, gateway, and code: - -```python -IMAGE = "registry.example.com/jupyter-sandbox:latest" -POLICY = EXAMPLE_DIR / "my-policy.yaml" -NAME_PREFIX = "analysis" -WORKSPACE = "default" -CODE = 'print("hello from Jupyter")' -GATEWAY = None -``` - -`IMAGE` accepts an OCI image reference. The Python SDK does not currently build -a Dockerfile the way `openshell sandbox create --from` does, so build or publish -the image first. A custom image must provide `jupyter server`, a `python3` -kernel, and the non-root `sandbox` user and group selected by the policy. - -`policy.yaml` allows the system paths Jupyter needs and writable access to -`/sandbox` and `/tmp`. Its empty `network_policies` map denies outbound network -access. Exposing the loopback Jupyter server through OpenShell is inbound and -does not require an egress rule. - -## Proposed Python SDK APIs - -The example still has deliberate seams because the SDK does not expose all CLI -functionality: +The code runs in a Jupyter kernel inside the sandbox. You can also run code +interactively by opening the printed URL in your browser. -- The SDK can expose a service during sandbox creation. Public methods for - adding, querying, and deleting services after creation would support a - longer-lived sandbox workflow. -- Add a public sandbox configuration builder that accepts `image` and a - `SandboxPolicy`. The low-level client currently requires generated protobuf - types from the private `openshell._proto` package. -- Add `load_sandbox_policy()` with the same canonical YAML validation and - conversion as the CLI. This example only normalizes the - `filesystem_policy` field needed by its policy before parsing the protobuf. -- Add an image-source helper with CLI parity for OCI references, Dockerfiles, - and build directories. Until then, Python SDK callers must prepare the OCI - image before creating a sandbox. +The URL contains a Jupyter token. Treat it as a credential and do not share it. +The example requires a local gateway; it does not configure remote gateway +authentication for browser access. -With the remaining configuration APIs, `JupyterSandbox` could remove its -imports from `openshell._proto`. +Edit the constants at the top of `demo.py` to change the image, policy, +workspace, gateway, or command. The image must contain Jupyter Server and +Python, plus the `sandbox` user and group selected by the policy. diff --git a/examples/jupyter-sandbox/demo.py b/examples/jupyter-sandbox/demo.py index 59f940dc7f..233ed73210 100644 --- a/examples/jupyter-sandbox/demo.py +++ b/examples/jupyter-sandbox/demo.py @@ -1,49 +1,217 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Launch one Jupyter sandbox and submit code to its exposed kernel.""" +"""Launch Jupyter in one sandbox and print its local service URL.""" from __future__ import annotations +import json import secrets +import struct +import time +import uuid +from contextlib import closing, suppress +from datetime import UTC, datetime from pathlib import Path +from urllib.error import URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import ProxyHandler, Request, build_opener -from jupyter_sandbox import JupyterSandbox +import websocket +import yaml +from google.protobuf.json_format import ParseDict -from openshell import SandboxClient +from openshell import SandboxClient, ServiceExposure +from openshell._proto import openshell_pb2, sandbox_pb2 EXAMPLE_DIR = Path(__file__).resolve().parent - -# Configure the sandbox and the work submitted to its Jupyter kernel here. IMAGE = "openshell-jupyter-sandbox:local" POLICY = EXAMPLE_DIR / "policy.yaml" -NAME_PREFIX = "jupyter" WORKSPACE = "default" +GATEWAY: str | None = None # None selects the active gateway. CODE = "print(sum(i * i for i in range(10)))" -GATEWAY: str | None = None # None selects the active OpenShell gateway. + +_START_JUPYTER = """ +set -eu +umask 077 +IFS= read -r token +printf '%s' "$token" > /tmp/openshell-jupyter-token +export HOME=/sandbox +export JUPYTER_TOKEN_FILE=/tmp/openshell-jupyter-token +nohup jupyter server \ + --ServerApp.ip=127.0.0.1 \ + --ServerApp.port=8888 \ + --ServerApp.port_retries=0 \ + --ServerApp.open_browser=False \ + --ServerApp.root_dir=/sandbox \ + --ServerApp.terminals_enabled=False \ + /tmp/openshell-jupyter.log 2>&1 & +""".strip() + + +def sandbox_spec() -> openshell_pb2.SandboxSpec: + """Load the example policy into the SDK's current sandbox spec.""" + policy_data = yaml.safe_load(POLICY.read_text(encoding="utf-8")) + policy_data["filesystem"] = policy_data.pop("filesystem_policy") + return openshell_pb2.SandboxSpec( + template=openshell_pb2.SandboxTemplate(image=IMAGE), + policy=ParseDict(policy_data, sandbox_pb2.SandboxPolicy()), + ) + + +def browser_url(service_url: str, token: str) -> str: + """Add the Jupyter token to the URL returned by OpenShell.""" + parsed = urlsplit(service_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query["token"] = token + return urlunsplit(parsed._replace(query=urlencode(query))) + + +def execute_in_kernel(service_url: str, token: str, code: str) -> None: + """Submit code through the exposed Jupyter REST and WebSocket APIs.""" + base = service_url.rstrip("/") + opener = build_opener(ProxyHandler({})) + + def api_request(method: str, path: str, payload: dict | None = None) -> bytes: + data = json.dumps(payload).encode() if payload is not None else None + request = Request( + browser_url(f"{base}{path}", token), + data=data, + headers={"Content-Type": "application/json"}, + method=method, + ) + with opener.open(request, timeout=10) as response: + return response.read() + + deadline = time.monotonic() + 60 + while True: + try: + api_request("GET", "/api/status") + break + except (URLError, TimeoutError): + if time.monotonic() >= deadline: + raise RuntimeError( + "Jupyter did not become ready within 60 seconds" + ) from None + time.sleep(1) + + kernel = json.loads(api_request("POST", "/api/kernels", {"name": "python3"})) + kernel_id = kernel["id"] + try: + session_id = uuid.uuid4().hex + msg_id = uuid.uuid4().hex + channels = browser_url(f"{base}/api/kernels/{kernel_id}/channels", token) + parsed = urlsplit(channels) + query = dict(parse_qsl(parsed.query)) + query["session_id"] = session_id + socket_url = urlunsplit( + parsed._replace( + scheme="wss" if parsed.scheme == "https" else "ws", + query=urlencode(query), + ) + ) + + request = { + "channel": "shell", + "header": { + "date": datetime.now(UTC).isoformat(), + "msg_id": msg_id, + "msg_type": "execute_request", + "session": session_id, + "username": "openshell", + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "allow_stdin": False, + "stop_on_error": True, + "user_expressions": {}, + }, + } + + replied = idle = False + with closing( + websocket.create_connection( + socket_url, + http_no_proxy=[parsed.hostname or "localhost"], + suppress_origin=True, + timeout=30, + ) + ) as socket: + socket.send(json.dumps(request)) + while not (replied and idle): + message = socket.recv() + if isinstance(message, bytes): + # Jupyter's binary WebSocket framing starts with JSON offsets. + count = struct.unpack_from("!I", message)[0] + offsets = struct.unpack_from(f"!{count}I", message, 4) + end = offsets[1] if count > 1 else len(message) + message = message[offsets[0] : end] + event = json.loads(message) + if event.get("parent_header", {}).get("msg_id") != msg_id: + continue + kind = event.get("header", {}).get("msg_type") + content = event.get("content", {}) + if kind == "stream": + print(content.get("text", ""), end="") + elif kind in {"execute_result", "display_data"}: + print(content.get("data", {}).get("text/plain", "")) + elif kind == "error": + raise RuntimeError("\n".join(content.get("traceback", []))) + elif kind == "execute_reply": + replied = True + if content.get("status") == "error": + raise RuntimeError( + content.get("evalue", "kernel execution failed") + ) + elif kind == "status" and content.get("execution_state") == "idle": + idle = True + finally: + api_request("DELETE", f"/api/kernels/{kernel_id}") def main() -> None: - with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: - health = client.health() - print(f"Connected to OpenShell {health.version}") + token = secrets.token_urlsafe(32) + name = f"jupyter-{secrets.token_hex(3)}" - sandbox_name = f"{NAME_PREFIX}-{secrets.token_hex(3)}" - with JupyterSandbox( - client=client, - name=sandbox_name, + with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: + session = client.create_session( workspace=WORKSPACE, - image=IMAGE, - policy=POLICY, - ) as sandbox: - print(f"\nStarted sandbox {sandbox.name}") - print(f"Jupyter service: {sandbox.service_url}") - - print("\nCreating a kernel and submitting code through the service:") - print(CODE) - result = sandbox.execute(CODE) - print("\nResult:") - print(result, end="" if result.endswith("\n") else "\n") + name=name, + spec=sandbox_spec(), + service_exposures=[ServiceExposure(service="jupyter", target_port=8888)], + ) + try: + client.wait_ready(name, workspace=WORKSPACE) + started = session.exec( + ["/bin/sh", "-c", _START_JUPYTER], stdin=f"{token}\n".encode() + ) + if started.exit_code != 0: + raise RuntimeError(f"could not start Jupyter: {started.stderr}") + + print(f"Sandbox: {name}") + print( + "Open in your browser: " + f"{browser_url(session.sandbox.service_urls['jupyter'], token)}" + ) + + print("Jupyter kernel output:") + execute_in_kernel(session.sandbox.service_urls["jupyter"], token, CODE) + + with suppress(EOFError, KeyboardInterrupt): + input("Press Enter to delete the sandbox (or Ctrl-C to stop)...") + finally: + deletion = session.delete(allow_missing=True) + if deletion.sandbox_id: + client.wait_deleted( + name, + workspace=WORKSPACE, + expected_sandbox_id=deletion.sandbox_id, + ) if __name__ == "__main__": diff --git a/examples/jupyter-sandbox/jupyter_sandbox.py b/examples/jupyter-sandbox/jupyter_sandbox.py deleted file mode 100644 index 5742a7fc91..0000000000 --- a/examples/jupyter-sandbox/jupyter_sandbox.py +++ /dev/null @@ -1,514 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""One OpenShell sandbox exposing a token-authenticated Jupyter API service.""" - -from __future__ import annotations - -import importlib -import json -import re -import secrets -import struct -import time -import uuid -import warnings -from contextlib import AbstractContextManager -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING, Any -from urllib.error import HTTPError, URLError -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from urllib.request import ProxyHandler, Request, build_opener - -import yaml -from google.protobuf.json_format import ParseDict, ParseError - -from openshell import ServiceExposure -from openshell._proto import openshell_pb2, sandbox_pb2 - -if TYPE_CHECKING: - from openshell import ExecResult, SandboxClient, SandboxSession - -JUPYTER_PORT = 8888 -JUPYTER_SERVICE = "jupyter" -_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") -_START_JUPYTER = r""" -set -eu -umask 077 -token_file=/tmp/openshell-jupyter-token -runtime_dir=/tmp/openshell-jupyter-runtime -mkdir -p "$runtime_dir" -IFS= read -r token -printf '%s' "$token" > "$token_file" -export HOME=/sandbox -export JUPYTER_RUNTIME_DIR="$runtime_dir" -export JUPYTER_TOKEN_FILE="$token_file" -nohup jupyter server \ - --ServerApp.ip=127.0.0.1 \ - --ServerApp.port=8888 \ - --ServerApp.port_retries=0 \ - --ServerApp.open_browser=False \ - --ServerApp.root_dir=/sandbox \ - --ServerApp.terminals_enabled=False \ - /tmp/openshell-jupyter.log 2>&1 & -""".strip() - - -class JupyterSandboxError(RuntimeError): - """An OpenShell or Jupyter lifecycle operation failed.""" - - -class JupyterExecutionError(JupyterSandboxError): - """Code submitted to a Jupyter kernel failed.""" - - -class JupyterSandbox(AbstractContextManager["JupyterSandbox"]): - """Manage one sandbox, its Jupyter server, and its exposed service.""" - - def __init__( - self, - *, - client: SandboxClient, - name: str, - workspace: str, - image: str | Path, - policy: str | Path, - ready_timeout: float = 60.0, - execute_timeout: float = 60.0, - ) -> None: - if len(name) > 28 or not _NAME.fullmatch(name): - raise ValueError( - "name must be at most 28 lowercase letters, digits, or hyphens" - ) - if ready_timeout <= 0 or execute_timeout <= 0: - raise ValueError("timeouts must be greater than zero") - if not workspace: - raise ValueError("workspace must be non-empty") - - policy_path = Path(policy).expanduser().resolve() - if not policy_path.is_file(): - raise ValueError(f"policy does not exist: {policy_path}") - - image_reference = str(image) - if Path(image_reference).expanduser().exists(): - raise ValueError( - "the Python SDK requires an OCI image reference; build the " - "Dockerfile before launching the sandbox" - ) - - self.client = client - self.name = name - self.workspace = workspace - self.image = image_reference - self.policy = policy_path - self.ready_timeout = ready_timeout - self.execute_timeout = execute_timeout - self.service_url: str | None = None - - self._token = secrets.token_urlsafe(32) - self._session: SandboxSession | None = None - - def __enter__(self) -> JupyterSandbox: - try: - self._create_sandbox() - self._start_jupyter() - self._wait_until_ready() - except BaseException as error: - cleanup_errors = self._cleanup() - self._attach_cleanup_errors(error, cleanup_errors) - raise - return self - - def __exit__(self, exc_type, exc_value, traceback) -> bool: - cleanup_errors = self._cleanup() - if not cleanup_errors: - return False - if exc_value is not None: - self._attach_cleanup_errors(exc_value, cleanup_errors) - return False - raise BaseExceptionGroup( - f"failed to clean up Jupyter sandbox {self.name}", cleanup_errors - ) - - def execute(self, code: str) -> str: - """Execute code through the public Jupyter REST and WebSocket APIs.""" - if self.service_url is None: - raise RuntimeError("sandbox is only available inside the context") - - kernel = self._request_json( - "POST", "/api/kernels", {"name": "python3", "path": ""} - ) - kernel_id = kernel.get("id") - if not isinstance(kernel_id, str): - raise JupyterSandboxError("Jupyter did not return a kernel id") - - execution_error: BaseException | None = None - try: - return self._execute_on_kernel(kernel_id, code) - except BaseException as error: - execution_error = error - raise - finally: - try: - self._request("DELETE", f"/api/kernels/{kernel_id}") - except BaseException as error: - if execution_error is None: - raise - warnings.warn( - self._safe_message("failed to delete Jupyter kernel", error), - stacklevel=2, - ) - - def _create_sandbox(self) -> None: - self._session = self.client.create_session( - workspace=self.workspace, - name=self.name, - labels={"app": "jupyter", "managed-by": "jupyter-sandbox-example"}, - spec=self._sandbox_spec(), - service_exposures=[ - ServiceExposure(service=JUPYTER_SERVICE, target_port=JUPYTER_PORT) - ], - ) - service_url = self._session.sandbox.service_urls.get(JUPYTER_SERVICE, "") - if not service_url: - raise JupyterSandboxError( - "OpenShell did not return the Jupyter service URL" - ) - parsed = urlsplit(service_url) - host = parsed.hostname or "" - local_host = host in {"127.0.0.1", "::1", "localhost"} or host.endswith( - ".localhost" - ) - if parsed.scheme not in {"http", "https"} or not local_host: - raise JupyterSandboxError( - "this example supports services exposed by a local gateway only" - ) - self.service_url = service_url.rstrip("/") - self.client.wait_ready( - self.name, workspace=self.workspace, timeout_seconds=self.ready_timeout - ) - - def _start_jupyter(self) -> None: - self._exec( - ["/bin/sh", "-c", _START_JUPYTER], - stdin=f"{self._token}\n".encode(), - ) - - def _sandbox_spec(self) -> openshell_pb2.SandboxSpec: - try: - raw_policy = yaml.safe_load(self.policy.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError) as error: - raise JupyterSandboxError( - self._safe_message(f"could not load policy {self.policy}", error) - ) from None - if not isinstance(raw_policy, dict): - raise JupyterSandboxError("sandbox policy must be a YAML mapping") - - # The canonical policy file calls this field `filesystem_policy`, while - # the protobuf field is `filesystem`. The SDK does not yet expose the - # CLI's canonical YAML loader, so normalize the one alias used here. - policy_data = dict(raw_policy) - filesystem = policy_data.pop("filesystem_policy", None) - if filesystem is not None: - policy_data["filesystem"] = filesystem - try: - policy = ParseDict(policy_data, sandbox_pb2.SandboxPolicy()) - except (ParseError, TypeError, ValueError) as error: - raise JupyterSandboxError( - self._safe_message(f"invalid sandbox policy {self.policy}", error) - ) from None - - return openshell_pb2.SandboxSpec( - template=openshell_pb2.SandboxTemplate(image=self.image), - policy=policy, - providers=[], - ) - - def _wait_until_ready(self) -> None: - deadline = time.monotonic() + self.ready_timeout - while time.monotonic() < deadline: - try: - self._request("GET", "/api/status") - return - except (HTTPError, URLError, TimeoutError, JupyterSandboxError): - time.sleep(1) - - logs = self._jupyter_logs() - detail = f"\nJupyter log:\n{logs}" if logs else "" - raise JupyterSandboxError( - f"Jupyter in sandbox {self.name} was not ready after " - f"{self.ready_timeout:g} seconds{detail}" - ) - - def _jupyter_logs(self) -> str: - try: - result = self._exec( - [ - "/bin/sh", - "-c", - "tail -n 40 /tmp/openshell-jupyter.log 2>/dev/null || true", - ] - ) - except JupyterSandboxError: - return "" - return self._redact(result.stdout).strip() - - def _execute_on_kernel(self, kernel_id: str, code: str) -> str: - try: - websocket = importlib.import_module("websocket") - except ImportError as error: - raise JupyterSandboxError( - "websocket-client is required; install the example dependencies" - ) from error - - session_id = uuid.uuid4().hex - msg_id = uuid.uuid4().hex - channels_url = self._api_url(f"/api/kernels/{kernel_id}/channels") - channels_url = self._with_query(channels_url, session_id=session_id) - parsed = urlsplit(channels_url) - host = parsed.hostname or "localhost" - ws_url = urlunsplit( - ( - "wss" if parsed.scheme == "https" else "ws", - parsed.netloc, - parsed.path, - parsed.query, - parsed.fragment, - ) - ) - - try: - socket = websocket.create_connection( - ws_url, - http_no_proxy=[host], - suppress_origin=True, - timeout=min(5.0, self.execute_timeout), - ) - except BaseException as error: - raise JupyterSandboxError( - self._safe_message("could not open Jupyter kernel channels", error) - ) from None - - request = { - "channel": "shell", - "header": { - "date": datetime.now(UTC).isoformat(), - "msg_id": msg_id, - "msg_type": "execute_request", - "session": session_id, - "username": "openshell", - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "allow_stdin": False, - "code": code, - "silent": False, - "stop_on_error": True, - "store_history": True, - "user_expressions": {}, - }, - } - - output: list[str] = [] - error_text: str | None = None - idle = False - replied = False - deadline = time.monotonic() + self.execute_timeout - try: - socket.send(json.dumps(request)) - while not (idle and replied): - remaining = deadline - time.monotonic() - if remaining <= 0: - raise JupyterSandboxError( - f"execution timed out after {self.execute_timeout:g} seconds" - ) - socket.settimeout(min(1.0, remaining)) - try: - message = self._decode_message(socket.recv()) - except websocket.WebSocketTimeoutException: - continue - - parent = message.get("parent_header", {}) - if parent.get("msg_id") != msg_id: - continue - header = message.get("header", {}) - msg_type = header.get("msg_type") - content = message.get("content", {}) - - if msg_type == "stream": - output.append(str(content.get("text", ""))) - elif msg_type in {"display_data", "execute_result"}: - data = content.get("data", {}) - if "text/plain" in data: - output.append(str(data["text/plain"])) - elif msg_type == "error": - error_text = self._format_kernel_error(content) - elif msg_type == "execute_reply": - replied = True - if content.get("status") == "error" and error_text is None: - error_text = self._format_kernel_error(content) - elif msg_type == "status" and content.get("execution_state") == "idle": - idle = True - except JupyterSandboxError: - raise - except BaseException as error: - raise JupyterSandboxError( - self._safe_message("Jupyter kernel communication failed", error) - ) from None - finally: - socket.close() - - if error_text is not None: - raise JupyterExecutionError(error_text) - return "".join(output) - - @staticmethod - def _decode_message(payload: str | bytes) -> dict[str, Any]: - if isinstance(payload, str): - decoded = json.loads(payload) - else: - if len(payload) < 8: - raise JupyterSandboxError("received an invalid binary Jupyter message") - offset_count = struct.unpack_from("!I", payload)[0] - table_size = 4 * (offset_count + 1) - if offset_count < 1 or len(payload) < table_size: - raise JupyterSandboxError("received an invalid binary Jupyter message") - offsets = struct.unpack_from(f"!{offset_count}I", payload, 4) - start = offsets[0] - end = offsets[1] if offset_count > 1 else len(payload) - if start < table_size or end < start or end > len(payload): - raise JupyterSandboxError("received an invalid binary Jupyter message") - decoded = json.loads(payload[start:end]) - if not isinstance(decoded, dict): - raise JupyterSandboxError("received an invalid Jupyter message") - return decoded - - @staticmethod - def _format_kernel_error(content: dict[str, Any]) -> str: - traceback = content.get("traceback") - if isinstance(traceback, list) and traceback: - return _ANSI_ESCAPE.sub("", "\n".join(map(str, traceback))) - name = str(content.get("ename", "Error")) - value = str(content.get("evalue", "")) - return f"{name}: {value}".rstrip() - - def _request_json( - self, method: str, path: str, payload: dict[str, Any] | None = None - ) -> dict[str, Any]: - body = self._request(method, path, payload) - decoded = json.loads(body) - if not isinstance(decoded, dict): - raise JupyterSandboxError("Jupyter returned an unexpected response") - return decoded - - def _request( - self, method: str, path: str, payload: dict[str, Any] | None = None - ) -> bytes: - data = json.dumps(payload).encode() if payload is not None else None - request = Request( - self._api_url(path), - data=data, - headers={"Content-Type": "application/json"}, - method=method, - ) - try: - with build_opener(ProxyHandler({})).open(request, timeout=5) as response: - return response.read() - except HTTPError as error: - detail = self._redact(error.read().decode(errors="replace")).strip() - suffix = f": {detail}" if detail else "" - raise JupyterSandboxError( - f"Jupyter API {method} {path} returned HTTP {error.code}{suffix}" - ) from None - except URLError as error: - raise JupyterSandboxError( - self._safe_message(f"Jupyter API {method} {path} failed", error) - ) from None - - def _api_url(self, path: str) -> str: - if self.service_url is None: - raise RuntimeError("sandbox is only available inside the context") - base = self.service_url.rstrip("/") - return self._with_query(f"{base}/{path.lstrip('/')}") - - def _with_query(self, url: str, **extra: str) -> str: - parsed = urlsplit(url) - query = dict(parse_qsl(parsed.query, keep_blank_values=True)) - query["token"] = self._token - query.update(extra) - return urlunsplit( - ( - parsed.scheme, - parsed.netloc, - parsed.path, - urlencode(query), - parsed.fragment, - ) - ) - - def _cleanup(self) -> list[BaseException]: - errors: list[BaseException] = [] - if self._session is not None: - try: - deletion = self._session.delete(allow_missing=True) - if deletion.sandbox_id: - self.client.wait_deleted( - self.name, - workspace=self.workspace, - timeout_seconds=self.ready_timeout, - expected_sandbox_id=deletion.sandbox_id, - ) - except BaseException as error: - if "not found" not in str(error).lower(): - errors.append(error) - finally: - self._session = None - self.service_url = None - return errors - - @staticmethod - def _attach_cleanup_errors( - original: BaseException, cleanup_errors: list[BaseException] - ) -> None: - for error in cleanup_errors: - note = f"cleanup also failed: {JupyterSandbox._error_text(error)}" - if hasattr(original, "add_note"): - original.add_note(note) - warnings.warn(note, stacklevel=3) - - def _exec( - self, - command: list[str], - *, - stdin: bytes | None = None, - ) -> ExecResult: - if self._session is None: - raise RuntimeError("sandbox is only available inside the context") - result = self._session.exec( - command, - stdin=stdin, - timeout_seconds=max(1, round(self.ready_timeout)), - ) - if result.exit_code != 0: - detail = self._redact(result.stderr or result.stdout).strip() - suffix = f": {detail}" if detail else "" - raise JupyterSandboxError( - f"sandbox command failed ({result.exit_code}): " - f"{' '.join(command)}{suffix}" - ) - return result - - def _redact(self, value: str) -> str: - value = value.replace(self._token, "") - return re.sub(r"([?&]token=)[^&\s]+", r"\1", value) - - def _safe_message(self, prefix: str, error: BaseException) -> str: - return f"{prefix}: {self._redact(self._error_text(error))}" - - @staticmethod - def _error_text(error: BaseException) -> str: - return str(error) or type(error).__name__ diff --git a/examples/jupyter-sandbox/test_demo.py b/examples/jupyter-sandbox/test_demo.py new file mode 100644 index 0000000000..e668b4400b --- /dev/null +++ b/examples/jupyter-sandbox/test_demo.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check that the example submits code to a Jupyter kernel.""" + +from __future__ import annotations + +import json +import struct +from io import BytesIO +from typing import TYPE_CHECKING, Any + +import demo + +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from _pytest.monkeypatch import MonkeyPatch + + +def test_execute_in_kernel( + monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + methods: list[str] = [] + + class Opener: + def open(self, request: Any, *, timeout: int) -> BytesIO: + assert timeout == 10 + assert "token=test-token" in request.full_url + methods.append(request.get_method()) + if request.get_method() == "POST": + return BytesIO(b'{"id":"kernel-id"}') + return BytesIO(b"{}") + + class Socket: + def __init__(self) -> None: + self.messages: list[str | bytes] = [] + self.closed = False + + def send(self, payload: str) -> None: + request = json.loads(payload) + assert request["content"]["code"] == "print(285)" + msg_id = request["header"]["msg_id"] + + def event(kind: str, content: dict[str, Any]) -> str: + return json.dumps( + { + "parent_header": {"msg_id": msg_id}, + "header": {"msg_type": kind}, + "content": content, + } + ) + + stream = event("stream", {"text": "285\n"}).encode() + self.messages = [ + struct.pack("!II", 1, 8) + stream, + event("status", {"execution_state": "idle"}), + event("execute_reply", {"status": "ok"}), + ] + + def recv(self) -> str | bytes: + return self.messages.pop(0) + + def close(self) -> None: + self.closed = True + + socket = Socket() + monkeypatch.setattr(demo, "build_opener", lambda _proxy: Opener()) + monkeypatch.setattr( + demo.websocket, "create_connection", lambda *_args, **_kwargs: socket + ) + + demo.execute_in_kernel( + "http://jupyter.openshell.localhost:17670/", "test-token", "print(285)" + ) + + assert capsys.readouterr().out == "285\n" + assert methods == ["GET", "POST", "DELETE"] + assert socket.closed diff --git a/examples/jupyter-sandbox/test_jupyter_sandbox.py b/examples/jupyter-sandbox/test_jupyter_sandbox.py deleted file mode 100644 index 05546ae7c4..0000000000 --- a/examples/jupyter-sandbox/test_jupyter_sandbox.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Lifecycle checks for the SDK-backed Jupyter example.""" - -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, cast - -import pytest -from jupyter_sandbox import JupyterSandbox, JupyterSandboxError - -if TYPE_CHECKING: - from openshell import SandboxClient - - -class FakeSession: - def __init__(self, service_url: str | None) -> None: - self.sandbox = SimpleNamespace( - service_urls={"jupyter": service_url} if service_url else {} - ) - self.deleted = False - - def delete(self, *, allow_missing: bool = False) -> SimpleNamespace: - assert allow_missing - self.deleted = True - return SimpleNamespace(sandbox_id="sandbox-id") - - -class FakeClient: - def __init__(self, service_url: str | None) -> None: - self.session = FakeSession(service_url) - self.create_args: dict[str, Any] | None = None - self.waited_for_deletion: dict[str, Any] | None = None - - def create_session(self, **kwargs: Any) -> FakeSession: - self.create_args = kwargs - return self.session - - def wait_ready(self, name: str, **kwargs: Any) -> None: - assert name == "jupyter-test" - assert kwargs["workspace"] == "default" - - def wait_deleted(self, name: str, **kwargs: Any) -> None: - assert name == "jupyter-test" - self.waited_for_deletion = kwargs - - -def test_exposes_service_and_cleans_up_with_sandbox( - monkeypatch: pytest.MonkeyPatch, -) -> None: - client = FakeClient("http://jupyter.openshell.localhost:17670/") - monkeypatch.setattr(JupyterSandbox, "_start_jupyter", lambda _self: None) - monkeypatch.setattr(JupyterSandbox, "_wait_until_ready", lambda _self: None) - - with JupyterSandbox( - client=cast("SandboxClient", client), - name="jupyter-test", - workspace="default", - image="jupyter:local", - policy=Path(__file__).with_name("policy.yaml"), - ) as sandbox: - assert sandbox.service_url == "http://jupyter.openshell.localhost:17670" - assert client.create_args is not None - exposure = client.create_args["service_exposures"][0] - assert (exposure.service, exposure.target_port) == ("jupyter", 8888) - - assert client.session.deleted - assert client.waited_for_deletion is not None - assert client.waited_for_deletion["expected_sandbox_id"] == "sandbox-id" - - -def test_missing_service_url_still_deletes_created_sandbox() -> None: - client = FakeClient(None) - - with ( - pytest.raises(JupyterSandboxError, match="service URL"), - JupyterSandbox( - client=cast("SandboxClient", client), - name="jupyter-test", - workspace="default", - image="jupyter:local", - policy=Path(__file__).with_name("policy.yaml"), - ), - ): - pytest.fail("context should not have started") - - assert client.session.deleted From 7d36a548d9a63acadccd9f1a9f0e6a5c9d87da1c Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 15:50:36 -0700 Subject: [PATCH 5/8] feat(examples): execute notebooks on sandbox Jupyter kernel Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/.gitignore | 2 + examples/jupyter-sandbox/Dockerfile | 4 +- examples/jupyter-sandbox/README.md | 65 +++--- examples/jupyter-sandbox/demo.ipynb | 35 +++ examples/jupyter-sandbox/demo.py | 218 ------------------ .../jupyter_nbconvert_config.py | 44 ++++ examples/jupyter-sandbox/launch.py | 119 ++++++++++ examples/jupyter-sandbox/test_demo.py | 78 ------- 8 files changed, 236 insertions(+), 329 deletions(-) create mode 100644 examples/jupyter-sandbox/.gitignore create mode 100644 examples/jupyter-sandbox/demo.ipynb delete mode 100644 examples/jupyter-sandbox/demo.py create mode 100644 examples/jupyter-sandbox/jupyter_nbconvert_config.py create mode 100644 examples/jupyter-sandbox/launch.py delete mode 100644 examples/jupyter-sandbox/test_demo.py diff --git a/examples/jupyter-sandbox/.gitignore b/examples/jupyter-sandbox/.gitignore new file mode 100644 index 0000000000..356c44e28d --- /dev/null +++ b/examples/jupyter-sandbox/.gitignore @@ -0,0 +1,2 @@ +.jupyter-service.json +demo.nbconvert.ipynb diff --git a/examples/jupyter-sandbox/Dockerfile b/examples/jupyter-sandbox/Dockerfile index a0cb7767c1..991a059d07 100644 --- a/examples/jupyter-sandbox/Dockerfile +++ b/examples/jupyter-sandbox/Dockerfile @@ -24,5 +24,5 @@ WORKDIR /sandbox EXPOSE 8888 -# OpenShell replaces the image command with the sandbox supervisor. The -# example starts Jupyter after sandbox creation through the Python SDK. +# The example sets Jupyter as the sandbox's main command when it creates the +# sandbox; OpenShell does not run the image's default command. diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index f603042d15..e6db06dc79 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -1,46 +1,49 @@ # Jupyter Sandbox -Launch a Jupyter Server in one OpenShell sandbox, open its local service URL in -your browser, and submit code to a kernel through the exposed service. +Build a Jupyter image, launch it as an OpenShell service, then execute a local +notebook on a kernel inside the sandbox. -## Prerequisites +Run these commands from `examples/jupyter-sandbox`. The example needs a working +local OpenShell gateway, Docker, Python 3.11 or later, and +[uv](https://docs.astral.sh/uv/). -- A working local OpenShell gateway and its gateway configuration -- Docker to build the Jupyter image -- Python 3.11 or later and [uv](https://docs.astral.sh/uv/) - -## Run - -From this directory: +## 1. Build the container ```shell +docker build -t openshell-jupyter-sandbox:local . uv venv source .venv/bin/activate -uv pip install -e ../.. pyyaml websocket-client -docker build -t openshell-jupyter-sandbox:local . -python demo.py +uv pip install -e ../.. pyyaml jupyter-server==2.20.0 nbconvert==7.17.1 websocket-client ``` -The script creates a sandbox and exposes port 8888 as the `jupyter` service. It -starts a token-authenticated Jupyter Server on the sandbox's loopback address, -then prints a URL to open in your local browser. Jupyter may take a few seconds -to start. Keep the script running while you use it; press Enter or Ctrl-C to -delete the sandbox and its service. - -The script also creates a Jupyter kernel through the exposed REST API, sends -this code through the kernel's WebSocket channel, and prints `285`: +## 2. Launch the service -```python -print(sum(i * i for i in range(10))) +```shell +python launch.py ``` -The code runs in a Jupyter kernel inside the sandbox. You can also run code -interactively by opening the printed URL in your browser. +The sandbox starts Jupyter as its main command and exposes port 8888 as the +`jupyter` service. The launcher prints a token-authenticated browser URL and +waits. Keep it running while you execute the notebook. Press Enter or Ctrl-C +when done to delete the sandbox and service. + +## 3. Execute the notebook on the remote kernel + +In a second terminal, activate the same virtual environment and run: + +```shell +cd examples/jupyter-sandbox +source .venv/bin/activate +export JUPYTER_CONFIG_PATH="$PWD" +jupyter nbconvert --execute --to notebook demo.ipynb +``` -The URL contains a Jupyter token. Treat it as a credential and do not share it. -The example requires a local gateway; it does not configure remote gateway -authentication for browser access. +Open `demo.nbconvert.ipynb` locally to see the cell output, `285`. The notebook +file and executed result stay on your computer; the kernel runs in the sandbox. -Edit the constants at the top of `demo.py` to change the image, policy, -workspace, gateway, or command. The image must contain Jupyter Server and -Python, plus the `sandbox` user and group selected by the policy. +The launcher writes a mode-restricted `.jupyter-service.json` with the service +URL and token. `jupyter_nbconvert_config.py` uses it to direct nbconvert's +kernel manager to the service and authenticate the WebSocket connection. Both +files must be used from this directory, and `JUPYTER_CONFIG_PATH` tells +nbconvert where to find the config. The launcher removes the connection +file when it exits. Treat the printed URL as a credential. diff --git a/examples/jupyter-sandbox/demo.ipynb b/examples/jupyter-sandbox/demo.ipynb new file mode 100644 index 0000000000..d22c824df7 --- /dev/null +++ b/examples/jupyter-sandbox/demo.ipynb @@ -0,0 +1,35 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Jupyter in an OpenShell sandbox\n", + "This notebook runs on the sandboxed Jupyter kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sum-of-squares", + "metadata": {}, + "outputs": [], + "source": [ + "print(sum(i * i for i in range(10)))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/jupyter-sandbox/demo.py b/examples/jupyter-sandbox/demo.py deleted file mode 100644 index 233ed73210..0000000000 --- a/examples/jupyter-sandbox/demo.py +++ /dev/null @@ -1,218 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Launch Jupyter in one sandbox and print its local service URL.""" - -from __future__ import annotations - -import json -import secrets -import struct -import time -import uuid -from contextlib import closing, suppress -from datetime import UTC, datetime -from pathlib import Path -from urllib.error import URLError -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from urllib.request import ProxyHandler, Request, build_opener - -import websocket -import yaml -from google.protobuf.json_format import ParseDict - -from openshell import SandboxClient, ServiceExposure -from openshell._proto import openshell_pb2, sandbox_pb2 - -EXAMPLE_DIR = Path(__file__).resolve().parent -IMAGE = "openshell-jupyter-sandbox:local" -POLICY = EXAMPLE_DIR / "policy.yaml" -WORKSPACE = "default" -GATEWAY: str | None = None # None selects the active gateway. -CODE = "print(sum(i * i for i in range(10)))" - -_START_JUPYTER = """ -set -eu -umask 077 -IFS= read -r token -printf '%s' "$token" > /tmp/openshell-jupyter-token -export HOME=/sandbox -export JUPYTER_TOKEN_FILE=/tmp/openshell-jupyter-token -nohup jupyter server \ - --ServerApp.ip=127.0.0.1 \ - --ServerApp.port=8888 \ - --ServerApp.port_retries=0 \ - --ServerApp.open_browser=False \ - --ServerApp.root_dir=/sandbox \ - --ServerApp.terminals_enabled=False \ - /tmp/openshell-jupyter.log 2>&1 & -""".strip() - - -def sandbox_spec() -> openshell_pb2.SandboxSpec: - """Load the example policy into the SDK's current sandbox spec.""" - policy_data = yaml.safe_load(POLICY.read_text(encoding="utf-8")) - policy_data["filesystem"] = policy_data.pop("filesystem_policy") - return openshell_pb2.SandboxSpec( - template=openshell_pb2.SandboxTemplate(image=IMAGE), - policy=ParseDict(policy_data, sandbox_pb2.SandboxPolicy()), - ) - - -def browser_url(service_url: str, token: str) -> str: - """Add the Jupyter token to the URL returned by OpenShell.""" - parsed = urlsplit(service_url) - query = dict(parse_qsl(parsed.query, keep_blank_values=True)) - query["token"] = token - return urlunsplit(parsed._replace(query=urlencode(query))) - - -def execute_in_kernel(service_url: str, token: str, code: str) -> None: - """Submit code through the exposed Jupyter REST and WebSocket APIs.""" - base = service_url.rstrip("/") - opener = build_opener(ProxyHandler({})) - - def api_request(method: str, path: str, payload: dict | None = None) -> bytes: - data = json.dumps(payload).encode() if payload is not None else None - request = Request( - browser_url(f"{base}{path}", token), - data=data, - headers={"Content-Type": "application/json"}, - method=method, - ) - with opener.open(request, timeout=10) as response: - return response.read() - - deadline = time.monotonic() + 60 - while True: - try: - api_request("GET", "/api/status") - break - except (URLError, TimeoutError): - if time.monotonic() >= deadline: - raise RuntimeError( - "Jupyter did not become ready within 60 seconds" - ) from None - time.sleep(1) - - kernel = json.loads(api_request("POST", "/api/kernels", {"name": "python3"})) - kernel_id = kernel["id"] - try: - session_id = uuid.uuid4().hex - msg_id = uuid.uuid4().hex - channels = browser_url(f"{base}/api/kernels/{kernel_id}/channels", token) - parsed = urlsplit(channels) - query = dict(parse_qsl(parsed.query)) - query["session_id"] = session_id - socket_url = urlunsplit( - parsed._replace( - scheme="wss" if parsed.scheme == "https" else "ws", - query=urlencode(query), - ) - ) - - request = { - "channel": "shell", - "header": { - "date": datetime.now(UTC).isoformat(), - "msg_id": msg_id, - "msg_type": "execute_request", - "session": session_id, - "username": "openshell", - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "allow_stdin": False, - "stop_on_error": True, - "user_expressions": {}, - }, - } - - replied = idle = False - with closing( - websocket.create_connection( - socket_url, - http_no_proxy=[parsed.hostname or "localhost"], - suppress_origin=True, - timeout=30, - ) - ) as socket: - socket.send(json.dumps(request)) - while not (replied and idle): - message = socket.recv() - if isinstance(message, bytes): - # Jupyter's binary WebSocket framing starts with JSON offsets. - count = struct.unpack_from("!I", message)[0] - offsets = struct.unpack_from(f"!{count}I", message, 4) - end = offsets[1] if count > 1 else len(message) - message = message[offsets[0] : end] - event = json.loads(message) - if event.get("parent_header", {}).get("msg_id") != msg_id: - continue - kind = event.get("header", {}).get("msg_type") - content = event.get("content", {}) - if kind == "stream": - print(content.get("text", ""), end="") - elif kind in {"execute_result", "display_data"}: - print(content.get("data", {}).get("text/plain", "")) - elif kind == "error": - raise RuntimeError("\n".join(content.get("traceback", []))) - elif kind == "execute_reply": - replied = True - if content.get("status") == "error": - raise RuntimeError( - content.get("evalue", "kernel execution failed") - ) - elif kind == "status" and content.get("execution_state") == "idle": - idle = True - finally: - api_request("DELETE", f"/api/kernels/{kernel_id}") - - -def main() -> None: - token = secrets.token_urlsafe(32) - name = f"jupyter-{secrets.token_hex(3)}" - - with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: - session = client.create_session( - workspace=WORKSPACE, - name=name, - spec=sandbox_spec(), - service_exposures=[ServiceExposure(service="jupyter", target_port=8888)], - ) - try: - client.wait_ready(name, workspace=WORKSPACE) - started = session.exec( - ["/bin/sh", "-c", _START_JUPYTER], stdin=f"{token}\n".encode() - ) - if started.exit_code != 0: - raise RuntimeError(f"could not start Jupyter: {started.stderr}") - - print(f"Sandbox: {name}") - print( - "Open in your browser: " - f"{browser_url(session.sandbox.service_urls['jupyter'], token)}" - ) - - print("Jupyter kernel output:") - execute_in_kernel(session.sandbox.service_urls["jupyter"], token, CODE) - - with suppress(EOFError, KeyboardInterrupt): - input("Press Enter to delete the sandbox (or Ctrl-C to stop)...") - finally: - deletion = session.delete(allow_missing=True) - if deletion.sandbox_id: - client.wait_deleted( - name, - workspace=WORKSPACE, - expected_sandbox_id=deletion.sandbox_id, - ) - - -if __name__ == "__main__": - main() diff --git a/examples/jupyter-sandbox/jupyter_nbconvert_config.py b/examples/jupyter-sandbox/jupyter_nbconvert_config.py new file mode 100644 index 0000000000..22dbe56655 --- /dev/null +++ b/examples/jupyter-sandbox/jupyter_nbconvert_config.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Make nbconvert use the Jupyter service started by launch.py.""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import websocket +from jupyter_server.gateway.managers import GatewayKernelClient, GatewayKernelManager + +connection_file = Path(".jupyter-service.json") +if not connection_file.is_file(): + raise RuntimeError("Start the Jupyter sandbox with `python launch.py` first") +connection = json.loads(connection_file.read_text(encoding="utf-8")) +os.environ["JUPYTER_GATEWAY_URL"] = connection["url"] +os.environ["JUPYTER_GATEWAY_AUTH_TOKEN"] = connection["token"] + + +class AuthenticatedGatewayKernelClient(GatewayKernelClient): + """Send the Jupyter token on the gateway WebSocket connection.""" + + async def start_channels(self, *args, **kwargs): + original = websocket.create_connection + + def authenticated(*connection_args, **connection_kwargs): + connection_kwargs["header"] = { + "Authorization": f"token {connection['token']}" + } + return original(*connection_args, **connection_kwargs) + + # GatewayKernelClient forwards the token on REST but not WebSocket. + with patch.object(websocket, "create_connection", authenticated): + return await super().start_channels(*args, **kwargs) + + +class AuthenticatedGatewayKernelManager(GatewayKernelManager): + client_factory = AuthenticatedGatewayKernelClient + + +c = get_config() # noqa: F821 - provided by Jupyter's config loader +c.ExecutePreprocessor.kernel_manager_class = AuthenticatedGatewayKernelManager diff --git a/examples/jupyter-sandbox/launch.py b/examples/jupyter-sandbox/launch.py new file mode 100644 index 0000000000..97d8638c92 --- /dev/null +++ b/examples/jupyter-sandbox/launch.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Launch a Jupyter service in one OpenShell sandbox.""" + +from __future__ import annotations + +import json +import os +import secrets +import time +from contextlib import suppress +from pathlib import Path +from urllib.error import URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import ProxyHandler, Request, build_opener + +import yaml +from google.protobuf.json_format import ParseDict + +from openshell import SandboxClient, ServiceExposure +from openshell._proto import openshell_pb2, sandbox_pb2 + +EXAMPLE_DIR = Path(__file__).resolve().parent +CONNECTION = EXAMPLE_DIR / ".jupyter-service.json" +IMAGE = "openshell-jupyter-sandbox:local" +POLICY = EXAMPLE_DIR / "policy.yaml" +WORKSPACE = "default" +GATEWAY: str | None = None # None selects the active gateway. + + +def sandbox_spec(token: str) -> openshell_pb2.SandboxSpec: + policy_data = yaml.safe_load(POLICY.read_text(encoding="utf-8")) + policy_data["filesystem"] = policy_data.pop("filesystem_policy") + return openshell_pb2.SandboxSpec( + template=openshell_pb2.SandboxTemplate(image=IMAGE), + policy=ParseDict(policy_data, sandbox_pb2.SandboxPolicy()), + environment={"HOME": "/sandbox", "JUPYTER_TOKEN": token}, + command=[ + "jupyter", + "server", + "--ServerApp.ip=127.0.0.1", + "--ServerApp.port=8888", + "--ServerApp.port_retries=0", + "--ServerApp.open_browser=False", + "--ServerApp.root_dir=/sandbox", + "--ServerApp.terminals_enabled=False", + ], + ) + + +def browser_url(service_url: str, token: str) -> str: + parsed = urlsplit(service_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query["token"] = token + return urlunsplit(parsed._replace(query=urlencode(query))) + + +def wait_for_jupyter(service_url: str, token: str) -> None: + opener = build_opener(ProxyHandler({})) + url = browser_url(f"{service_url.rstrip('/')}/api/status", token) + deadline = time.monotonic() + 60 + while True: + try: + with opener.open(Request(url), timeout=5): + return + except (URLError, TimeoutError): + if time.monotonic() >= deadline: + raise RuntimeError( + "Jupyter did not become ready within 60 seconds" + ) from None + time.sleep(1) + + +def main() -> None: + token = secrets.token_urlsafe(32) + name = f"jupyter-{secrets.token_hex(3)}" + + with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: + session = client.create_session( + workspace=WORKSPACE, + name=name, + spec=sandbox_spec(token), + service_exposures=[ServiceExposure(service="jupyter", target_port=8888)], + ) + connection_created = False + try: + client.wait_ready(name, workspace=WORKSPACE) + service_url = session.sandbox.service_urls["jupyter"] + wait_for_jupyter(service_url, token) + + fd = os.open(CONNECTION, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + connection_created = True + with os.fdopen(fd, "w", encoding="utf-8") as output: + json.dump({"url": service_url.rstrip("/"), "token": token}, output) + + print(f"Sandbox: {name}") + print(f"Browser: {browser_url(service_url, token)}") + print("In another terminal, run:") + print(f" cd {EXAMPLE_DIR}") + print(" source .venv/bin/activate") + print(' export JUPYTER_CONFIG_PATH="$PWD"') + print(" jupyter nbconvert --execute --to notebook demo.ipynb") + with suppress(EOFError, KeyboardInterrupt): + input("Press Enter to delete the sandbox (or Ctrl-C to stop)...") + finally: + if connection_created: + CONNECTION.unlink(missing_ok=True) + deletion = session.delete(allow_missing=True) + if deletion.sandbox_id: + client.wait_deleted( + name, + workspace=WORKSPACE, + expected_sandbox_id=deletion.sandbox_id, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/jupyter-sandbox/test_demo.py b/examples/jupyter-sandbox/test_demo.py deleted file mode 100644 index e668b4400b..0000000000 --- a/examples/jupyter-sandbox/test_demo.py +++ /dev/null @@ -1,78 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Check that the example submits code to a Jupyter kernel.""" - -from __future__ import annotations - -import json -import struct -from io import BytesIO -from typing import TYPE_CHECKING, Any - -import demo - -if TYPE_CHECKING: - from _pytest.capture import CaptureFixture - from _pytest.monkeypatch import MonkeyPatch - - -def test_execute_in_kernel( - monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] -) -> None: - methods: list[str] = [] - - class Opener: - def open(self, request: Any, *, timeout: int) -> BytesIO: - assert timeout == 10 - assert "token=test-token" in request.full_url - methods.append(request.get_method()) - if request.get_method() == "POST": - return BytesIO(b'{"id":"kernel-id"}') - return BytesIO(b"{}") - - class Socket: - def __init__(self) -> None: - self.messages: list[str | bytes] = [] - self.closed = False - - def send(self, payload: str) -> None: - request = json.loads(payload) - assert request["content"]["code"] == "print(285)" - msg_id = request["header"]["msg_id"] - - def event(kind: str, content: dict[str, Any]) -> str: - return json.dumps( - { - "parent_header": {"msg_id": msg_id}, - "header": {"msg_type": kind}, - "content": content, - } - ) - - stream = event("stream", {"text": "285\n"}).encode() - self.messages = [ - struct.pack("!II", 1, 8) + stream, - event("status", {"execution_state": "idle"}), - event("execute_reply", {"status": "ok"}), - ] - - def recv(self) -> str | bytes: - return self.messages.pop(0) - - def close(self) -> None: - self.closed = True - - socket = Socket() - monkeypatch.setattr(demo, "build_opener", lambda _proxy: Opener()) - monkeypatch.setattr( - demo.websocket, "create_connection", lambda *_args, **_kwargs: socket - ) - - demo.execute_in_kernel( - "http://jupyter.openshell.localhost:17670/", "test-token", "print(285)" - ) - - assert capsys.readouterr().out == "285\n" - assert methods == ["GET", "POST", "DELETE"] - assert socket.closed From a2fc87c554d6ccd9393fad70b68f9b6eb038a992 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 16:25:00 -0700 Subject: [PATCH 6/8] docs(examples): use CLI for Jupyter sandbox setup Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/.gitignore | 1 - examples/jupyter-sandbox/Dockerfile | 3 +- examples/jupyter-sandbox/README.md | 62 +++++---- .../jupyter_nbconvert_config.json | 5 + .../jupyter_nbconvert_config.py | 44 ------- examples/jupyter-sandbox/launch.py | 119 ------------------ 6 files changed, 46 insertions(+), 188 deletions(-) create mode 100644 examples/jupyter-sandbox/jupyter_nbconvert_config.json delete mode 100644 examples/jupyter-sandbox/jupyter_nbconvert_config.py delete mode 100644 examples/jupyter-sandbox/launch.py diff --git a/examples/jupyter-sandbox/.gitignore b/examples/jupyter-sandbox/.gitignore index 356c44e28d..9e8e5a1aaa 100644 --- a/examples/jupyter-sandbox/.gitignore +++ b/examples/jupyter-sandbox/.gitignore @@ -1,2 +1 @@ -.jupyter-service.json demo.nbconvert.ipynb diff --git a/examples/jupyter-sandbox/Dockerfile b/examples/jupyter-sandbox/Dockerfile index 991a059d07..5de9fa2c7c 100644 --- a/examples/jupyter-sandbox/Dockerfile +++ b/examples/jupyter-sandbox/Dockerfile @@ -24,5 +24,4 @@ WORKDIR /sandbox EXPOSE 8888 -# The example sets Jupyter as the sandbox's main command when it creates the -# sandbox; OpenShell does not run the image's default command. +# The OpenShell CLI starts Jupyter as the sandbox's main command. diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index e6db06dc79..f20fd839a4 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -1,11 +1,11 @@ # Jupyter Sandbox -Build a Jupyter image, launch it as an OpenShell service, then execute a local -notebook on a kernel inside the sandbox. +Build a Jupyter image, expose its server through a local OpenShell gateway, +then execute a local notebook on a kernel inside the sandbox. -Run these commands from `examples/jupyter-sandbox`. The example needs a working -local OpenShell gateway, Docker, Python 3.11 or later, and -[uv](https://docs.astral.sh/uv/). +Run these commands from `examples/jupyter-sandbox`. You need a local, +loopback-bound OpenShell gateway backed by Docker, plus Docker, the `openshell` +CLI, and [uv](https://docs.astral.sh/uv/) on the host. ## 1. Build the container @@ -13,37 +13,55 @@ local OpenShell gateway, Docker, Python 3.11 or later, and docker build -t openshell-jupyter-sandbox:local . uv venv source .venv/bin/activate -uv pip install -e ../.. pyyaml jupyter-server==2.20.0 nbconvert==7.17.1 websocket-client +uv pip install jupyter-server==2.20.0 nbconvert==7.17.1 ``` ## 2. Launch the service ```shell -python launch.py +openshell sandbox create \ + --name jupyter-demo \ + --from openshell-jupyter-sandbox:local \ + --policy policy.yaml \ + --env HOME=/sandbox \ + --expose 8888 \ + --detach --no-tty \ + -- jupyter server \ + --ServerApp.ip=127.0.0.1 \ + --ServerApp.port=8888 \ + --ServerApp.port_retries=0 \ + --ServerApp.open_browser=False \ + --ServerApp.root_dir=/sandbox \ + --ServerApp.terminals_enabled=False \ + --ServerApp.allow_remote_access=True \ + --ServerApp.disable_check_xsrf=True \ + --IdentityProvider.token='' ``` -The sandbox starts Jupyter as its main command and exposes port 8888 as the -`jupyter` service. The launcher prints a token-authenticated browser URL and -waits. Keep it running while you execute the notebook. Press Enter or Ctrl-C -when done to delete the sandbox and service. +The CLI prints the exposed service URL after the sandbox is ready. Jupyter +starts as the sandbox's main process and listens on its loopback port. + +This example disables Jupyter authentication and XSRF checks because Jupyter's +remote kernel client does not authenticate its WebSocket connection. Use it only +with a local, loopback-bound gateway: any local process that can reach the +service URL can run code in the sandbox. ## 3. Execute the notebook on the remote kernel -In a second terminal, activate the same virtual environment and run: +Set `JUPYTER_GATEWAY_URL` to the service URL printed in step 2: ```shell -cd examples/jupyter-sandbox -source .venv/bin/activate +export JUPYTER_GATEWAY_URL='http://default--jupyter-demo.openshell.localhost:/' export JUPYTER_CONFIG_PATH="$PWD" jupyter nbconvert --execute --to notebook demo.ipynb ``` -Open `demo.nbconvert.ipynb` locally to see the cell output, `285`. The notebook -file and executed result stay on your computer; the kernel runs in the sandbox. +The JSON config in this directory selects Jupyter's remote kernel manager. +`demo.nbconvert.ipynb` stays on your computer and contains the output `285`; +its code runs in the sandbox kernel. + +When finished, delete the sandbox and its service: -The launcher writes a mode-restricted `.jupyter-service.json` with the service -URL and token. `jupyter_nbconvert_config.py` uses it to direct nbconvert's -kernel manager to the service and authenticate the WebSocket connection. Both -files must be used from this directory, and `JUPYTER_CONFIG_PATH` tells -nbconvert where to find the config. The launcher removes the connection -file when it exits. Treat the printed URL as a credential. +```shell +openshell sandbox delete jupyter-demo +``` diff --git a/examples/jupyter-sandbox/jupyter_nbconvert_config.json b/examples/jupyter-sandbox/jupyter_nbconvert_config.json new file mode 100644 index 0000000000..0830213c51 --- /dev/null +++ b/examples/jupyter-sandbox/jupyter_nbconvert_config.json @@ -0,0 +1,5 @@ +{ + "ExecutePreprocessor": { + "kernel_manager_class": "jupyter_server.gateway.managers.GatewayKernelManager" + } +} diff --git a/examples/jupyter-sandbox/jupyter_nbconvert_config.py b/examples/jupyter-sandbox/jupyter_nbconvert_config.py deleted file mode 100644 index 22dbe56655..0000000000 --- a/examples/jupyter-sandbox/jupyter_nbconvert_config.py +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Make nbconvert use the Jupyter service started by launch.py.""" - -import json -import os -from pathlib import Path -from unittest.mock import patch - -import websocket -from jupyter_server.gateway.managers import GatewayKernelClient, GatewayKernelManager - -connection_file = Path(".jupyter-service.json") -if not connection_file.is_file(): - raise RuntimeError("Start the Jupyter sandbox with `python launch.py` first") -connection = json.loads(connection_file.read_text(encoding="utf-8")) -os.environ["JUPYTER_GATEWAY_URL"] = connection["url"] -os.environ["JUPYTER_GATEWAY_AUTH_TOKEN"] = connection["token"] - - -class AuthenticatedGatewayKernelClient(GatewayKernelClient): - """Send the Jupyter token on the gateway WebSocket connection.""" - - async def start_channels(self, *args, **kwargs): - original = websocket.create_connection - - def authenticated(*connection_args, **connection_kwargs): - connection_kwargs["header"] = { - "Authorization": f"token {connection['token']}" - } - return original(*connection_args, **connection_kwargs) - - # GatewayKernelClient forwards the token on REST but not WebSocket. - with patch.object(websocket, "create_connection", authenticated): - return await super().start_channels(*args, **kwargs) - - -class AuthenticatedGatewayKernelManager(GatewayKernelManager): - client_factory = AuthenticatedGatewayKernelClient - - -c = get_config() # noqa: F821 - provided by Jupyter's config loader -c.ExecutePreprocessor.kernel_manager_class = AuthenticatedGatewayKernelManager diff --git a/examples/jupyter-sandbox/launch.py b/examples/jupyter-sandbox/launch.py deleted file mode 100644 index 97d8638c92..0000000000 --- a/examples/jupyter-sandbox/launch.py +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Launch a Jupyter service in one OpenShell sandbox.""" - -from __future__ import annotations - -import json -import os -import secrets -import time -from contextlib import suppress -from pathlib import Path -from urllib.error import URLError -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from urllib.request import ProxyHandler, Request, build_opener - -import yaml -from google.protobuf.json_format import ParseDict - -from openshell import SandboxClient, ServiceExposure -from openshell._proto import openshell_pb2, sandbox_pb2 - -EXAMPLE_DIR = Path(__file__).resolve().parent -CONNECTION = EXAMPLE_DIR / ".jupyter-service.json" -IMAGE = "openshell-jupyter-sandbox:local" -POLICY = EXAMPLE_DIR / "policy.yaml" -WORKSPACE = "default" -GATEWAY: str | None = None # None selects the active gateway. - - -def sandbox_spec(token: str) -> openshell_pb2.SandboxSpec: - policy_data = yaml.safe_load(POLICY.read_text(encoding="utf-8")) - policy_data["filesystem"] = policy_data.pop("filesystem_policy") - return openshell_pb2.SandboxSpec( - template=openshell_pb2.SandboxTemplate(image=IMAGE), - policy=ParseDict(policy_data, sandbox_pb2.SandboxPolicy()), - environment={"HOME": "/sandbox", "JUPYTER_TOKEN": token}, - command=[ - "jupyter", - "server", - "--ServerApp.ip=127.0.0.1", - "--ServerApp.port=8888", - "--ServerApp.port_retries=0", - "--ServerApp.open_browser=False", - "--ServerApp.root_dir=/sandbox", - "--ServerApp.terminals_enabled=False", - ], - ) - - -def browser_url(service_url: str, token: str) -> str: - parsed = urlsplit(service_url) - query = dict(parse_qsl(parsed.query, keep_blank_values=True)) - query["token"] = token - return urlunsplit(parsed._replace(query=urlencode(query))) - - -def wait_for_jupyter(service_url: str, token: str) -> None: - opener = build_opener(ProxyHandler({})) - url = browser_url(f"{service_url.rstrip('/')}/api/status", token) - deadline = time.monotonic() + 60 - while True: - try: - with opener.open(Request(url), timeout=5): - return - except (URLError, TimeoutError): - if time.monotonic() >= deadline: - raise RuntimeError( - "Jupyter did not become ready within 60 seconds" - ) from None - time.sleep(1) - - -def main() -> None: - token = secrets.token_urlsafe(32) - name = f"jupyter-{secrets.token_hex(3)}" - - with SandboxClient.from_active_cluster(cluster=GATEWAY) as client: - session = client.create_session( - workspace=WORKSPACE, - name=name, - spec=sandbox_spec(token), - service_exposures=[ServiceExposure(service="jupyter", target_port=8888)], - ) - connection_created = False - try: - client.wait_ready(name, workspace=WORKSPACE) - service_url = session.sandbox.service_urls["jupyter"] - wait_for_jupyter(service_url, token) - - fd = os.open(CONNECTION, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - connection_created = True - with os.fdopen(fd, "w", encoding="utf-8") as output: - json.dump({"url": service_url.rstrip("/"), "token": token}, output) - - print(f"Sandbox: {name}") - print(f"Browser: {browser_url(service_url, token)}") - print("In another terminal, run:") - print(f" cd {EXAMPLE_DIR}") - print(" source .venv/bin/activate") - print(' export JUPYTER_CONFIG_PATH="$PWD"') - print(" jupyter nbconvert --execute --to notebook demo.ipynb") - with suppress(EOFError, KeyboardInterrupt): - input("Press Enter to delete the sandbox (or Ctrl-C to stop)...") - finally: - if connection_created: - CONNECTION.unlink(missing_ok=True) - deletion = session.delete(allow_missing=True) - if deletion.sandbox_id: - client.wait_deleted( - name, - workspace=WORKSPACE, - expected_sandbox_id=deletion.sandbox_id, - ) - - -if __name__ == "__main__": - main() From 448ab4db7919e5ce323c8616b24b449525c1c58c Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 21:52:24 -0700 Subject: [PATCH 7/8] docs(examples): use published Jupyter image and gateway CLI Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/.gitignore | 2 +- examples/jupyter-sandbox/Dockerfile | 27 --------- examples/jupyter-sandbox/README.md | 56 +++++++++---------- .../jupyter_nbconvert_config.json | 5 -- examples/jupyter-sandbox/policy.yaml | 8 +-- 5 files changed, 32 insertions(+), 66 deletions(-) delete mode 100644 examples/jupyter-sandbox/Dockerfile delete mode 100644 examples/jupyter-sandbox/jupyter_nbconvert_config.json diff --git a/examples/jupyter-sandbox/.gitignore b/examples/jupyter-sandbox/.gitignore index 9e8e5a1aaa..c5de386761 100644 --- a/examples/jupyter-sandbox/.gitignore +++ b/examples/jupyter-sandbox/.gitignore @@ -1 +1 @@ -demo.nbconvert.ipynb +demo.executed.ipynb diff --git a/examples/jupyter-sandbox/Dockerfile b/examples/jupyter-sandbox/Dockerfile deleted file mode 100644 index 5de9fa2c7c..0000000000 --- a/examples/jupyter-sandbox/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -FROM ghcr.io/astral-sh/uv:0.11.28 AS uv -FROM python:3.13-slim - -COPY --from=uv /uv /uvx /bin/ - -# OpenShell requires iproute2 for network isolation. nftables enables bypass -# detection, while procps is useful for inspecting the notebook processes. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates iproute2 nftables procps \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --no-cache \ - jupyter-server==2.20.0 \ - ipykernel==7.3.0 - -RUN groupadd --gid 1000 sandbox \ - && useradd --uid 1000 --gid sandbox --home-dir /sandbox \ - --no-create-home --shell /bin/sh sandbox \ - && install -d -o sandbox -g sandbox /sandbox -WORKDIR /sandbox - -EXPOSE 8888 - -# The OpenShell CLI starts Jupyter as the sandbox's main command. diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index f20fd839a4..00967054ee 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -1,29 +1,31 @@ # Jupyter Sandbox -Build a Jupyter image, expose its server through a local OpenShell gateway, -then execute a local notebook on a kernel inside the sandbox. +Run a published Jupyter image in OpenShell, expose its server locally, and +execute a local notebook on the sandboxed kernel. -Run these commands from `examples/jupyter-sandbox`. You need a local, -loopback-bound OpenShell gateway backed by Docker, plus Docker, the `openshell` -CLI, and [uv](https://docs.astral.sh/uv/) on the host. +Run these commands from `examples/jupyter-sandbox`. You need a local OpenShell +gateway, the `openshell` CLI, Cargo, and OpenSSL on the host. -## 1. Build the container +## 1. Install the notebook CLI + +Install the [Jupyter community notebook CLI](https://github.com/jupyter-ai-contrib/nb-cli): ```shell -docker build -t openshell-jupyter-sandbox:local . -uv venv -source .venv/bin/activate -uv pip install jupyter-server==2.20.0 nbconvert==7.17.1 +cargo install nb-cli --version 0.0.10 --locked ``` +OpenShell pulls the published `quay.io/jupyter/base-notebook:2026-04-27` image +when it creates the sandbox. The image includes Jupyter Server and a Python +kernel, so no container build is needed. + ## 2. Launch the service ```shell +JUPYTER_TOKEN="$(openssl rand -hex 32)" openshell sandbox create \ --name jupyter-demo \ - --from openshell-jupyter-sandbox:local \ + --from quay.io/jupyter/base-notebook:2026-04-27 \ --policy policy.yaml \ - --env HOME=/sandbox \ --expose 8888 \ --detach --no-tty \ -- jupyter server \ @@ -31,34 +33,30 @@ openshell sandbox create \ --ServerApp.port=8888 \ --ServerApp.port_retries=0 \ --ServerApp.open_browser=False \ - --ServerApp.root_dir=/sandbox \ + --ServerApp.root_dir=/home/jovyan \ --ServerApp.terminals_enabled=False \ --ServerApp.allow_remote_access=True \ - --ServerApp.disable_check_xsrf=True \ - --IdentityProvider.token='' + --IdentityProvider.token="$JUPYTER_TOKEN" ``` -The CLI prints the exposed service URL after the sandbox is ready. Jupyter -starts as the sandbox's main process and listens on its loopback port. - -This example disables Jupyter authentication and XSRF checks because Jupyter's -remote kernel client does not authenticate its WebSocket connection. Use it only -with a local, loopback-bound gateway: any local process that can reach the -service URL can run code in the sandbox. +The CLI prints the service URL after the sandbox is ready. Jupyter starts as +the sandbox's main process and listens on its loopback port. Keep the token in +this shell for step 3. ## 3. Execute the notebook on the remote kernel -Set `JUPYTER_GATEWAY_URL` to the service URL printed in step 2: +Use the service URL printed in step 2 as the `--gateway` value: ```shell -export JUPYTER_GATEWAY_URL='http://default--jupyter-demo.openshell.localhost:/' -export JUPYTER_CONFIG_PATH="$PWD" -jupyter nbconvert --execute --to notebook demo.ipynb +cp demo.ipynb demo.executed.ipynb +nb execute demo.executed.ipynb \ + --gateway 'http://default--jupyter-demo.openshell.localhost:/' \ + --gateway-token "$JUPYTER_TOKEN" ``` -The JSON config in this directory selects Jupyter's remote kernel manager. -`demo.nbconvert.ipynb` stays on your computer and contains the output `285`; -its code runs in the sandbox kernel. +The command writes `285` into `demo.executed.ipynb` on your computer. Its code +runs in a Jupyter kernel inside the sandbox. The `nb` CLI accepts the service +URL as a flag and authenticates its REST and WebSocket connections. When finished, delete the sandbox and its service: diff --git a/examples/jupyter-sandbox/jupyter_nbconvert_config.json b/examples/jupyter-sandbox/jupyter_nbconvert_config.json deleted file mode 100644 index 0830213c51..0000000000 --- a/examples/jupyter-sandbox/jupyter_nbconvert_config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ExecutePreprocessor": { - "kernel_manager_class": "jupyter_server.gateway.managers.GatewayKernelManager" - } -} diff --git a/examples/jupyter-sandbox/policy.yaml b/examples/jupyter-sandbox/policy.yaml index 67db855e1c..3fb812a8b6 100644 --- a/examples/jupyter-sandbox/policy.yaml +++ b/examples/jupyter-sandbox/policy.yaml @@ -5,15 +5,15 @@ version: 1 filesystem_policy: include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] + read_only: [/opt, /usr, /lib, /proc, /dev/urandom, /etc, /var/log] + read_write: [/home/jovyan, /tmp, /dev/null] landlock: compatibility: best_effort process: - run_as_user: sandbox - run_as_group: sandbox + run_as_user: jovyan + run_as_group: users # The Jupyter service is exposed inbound through the gateway. The notebook # does not need outbound network access for this demonstration. From c2d8392a81cb37bb50c8aead2fb1a72860f50c7b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 28 Sep 2026 22:31:24 -0700 Subject: [PATCH 8/8] docs(examples): execute Jupyter demo notebook in place Signed-off-by: Drew Newberry --- examples/jupyter-sandbox/.gitignore | 1 - examples/jupyter-sandbox/README.md | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 examples/jupyter-sandbox/.gitignore diff --git a/examples/jupyter-sandbox/.gitignore b/examples/jupyter-sandbox/.gitignore deleted file mode 100644 index c5de386761..0000000000 --- a/examples/jupyter-sandbox/.gitignore +++ /dev/null @@ -1 +0,0 @@ -demo.executed.ipynb diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index 00967054ee..b855ea8e61 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -48,13 +48,12 @@ this shell for step 3. Use the service URL printed in step 2 as the `--gateway` value: ```shell -cp demo.ipynb demo.executed.ipynb -nb execute demo.executed.ipynb \ +nb execute demo.ipynb \ --gateway 'http://default--jupyter-demo.openshell.localhost:/' \ --gateway-token "$JUPYTER_TOKEN" ``` -The command writes `285` into `demo.executed.ipynb` on your computer. Its code +The command writes `285` into `demo.ipynb` on your computer. Its code runs in a Jupyter kernel inside the sandbox. The `nb` CLI accepts the service URL as a flag and authenticates its REST and WebSocket connections.