Files
arc53--docsgpt/application/sandbox/jupyter_gateway.py
T
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

677 lines
30 KiB
Python

"""Jupyter Kernel Gateway sandbox: stateful in-process kernels over REST + WebSocket."""
import base64
import hashlib
import json
import logging
import re
import threading
import time
import uuid
from typing import Dict, List, Optional
from urllib.parse import urlparse, urlunparse
import requests
import websocket
from application.sandbox.base import (
CodeSandbox,
DisplayData,
ExecResult,
Plot,
)
logger = logging.getLogger(__name__)
# Per-session workspace root inside the runner container. The kernel sets its
# cwd here so relative paths from LLM code and file in/out share one directory.
_WORKSPACE_ROOT = "/tmp/docsgpt-sandbox" # nosec B108 - controlled per-session sandbox workspace dir
# Marker the get_file helper prints around the base64 payload so we can extract
# it from interleaved stdout without a contents API (the gateway has none).
_FILE_BEGIN = "<<<DOCSGPT_FILE_BEGIN>>>"
_FILE_END = "<<<DOCSGPT_FILE_END>>>"
# Session ids become filesystem path components; allow only safe characters.
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# Kernel-side helper that resolves a workspace-relative path and rejects any
# absolute path or one that escapes the per-session workspace (path traversal).
_CONTAINMENT_SNIPPET = (
"def _resolve(_base, _rel):\n"
" import os as _os\n"
" if _os.path.isabs(_rel):\n"
" raise ValueError('absolute paths are not allowed')\n"
" _root = _os.path.realpath(_base)\n"
" _rp = _os.path.realpath(_os.path.join(_root, _rel))\n"
" if _rp != _root and not _rp.startswith(_root + _os.sep):\n"
" raise ValueError('path escapes the session workspace')\n"
" return _rp\n"
)
class _Kernel:
"""Tracks one gateway kernel plus the per-session workspace it executes in."""
def __init__(
self, kernel_id: str, workspace: str, session_id: Optional[str] = None
) -> None:
self.kernel_id = kernel_id
self.workspace = workspace
self.session_id = session_id
self.initialized = False
class JupyterKernelGatewaySandbox(CodeSandbox):
"""Drives one always-on Jupyter Kernel Gateway, one stateful kernel per session."""
# Raw-byte chunk size for a staged upload: each execute_request carries one
# chunk's base64 (~4 MB at 3 MB raw), well under the gateway's 10 MiB websocket
# frame cap. The 3-byte boundary keeps every base64 block self-contained.
_PUT_CHUNK_BYTES = 3 * 1024 * 1024
def __init__(
self,
gateway_url: str,
auth_token: Optional[str] = None,
kernel_name: str = "python3",
default_timeout: float = 60.0,
http_timeout: float = 10.0,
max_output_bytes: int = 8 * 1024 * 1024,
max_file_bytes: int = 10 * 1024 * 1024,
) -> None:
"""Configure the client; no kernel is created until ``open`` is called."""
self._base_url = gateway_url.rstrip("/")
self._auth_token = auth_token
self._kernel_name = kernel_name
self._default_timeout = default_timeout
self._http_timeout = http_timeout
self._max_output_bytes = max_output_bytes
self._max_file_bytes = max_file_bytes
self._kernels: Dict[str, _Kernel] = {}
# Timed-out kernels whose DELETE could not yet be confirmed. Immutable
# ids stay retryable here and are never reused as active runtimes.
self._quarantined_kernels: Dict[str, Optional[str]] = {}
self._lock = threading.Lock()
# Session ids with a create in flight; a second open() for the same id
# waits on this CV and reuses the result instead of double-creating a
# kernel (the idempotency contract SandboxManager.open relies on).
self._creating: set = set()
self._create_cv = threading.Condition(self._lock)
# -- HTTP helpers ----------------------------------------------------
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self._auth_token:
headers["Authorization"] = f"token {self._auth_token}"
return headers
def _ws_url(self, kernel_id: str) -> str:
# The token is sent only via the Authorization header (_ws_headers); keeping it
# out of the URL query avoids leaking it into gateway/proxy access logs.
parsed = urlparse(self._base_url)
scheme = "wss" if parsed.scheme == "https" else "ws"
path = f"/api/kernels/{kernel_id}/channels"
return urlunparse((scheme, parsed.netloc, path, "", "", ""))
def _get_kernel(self, session_id: str) -> _Kernel:
# A manager may cache a replacement handle, so normal traffic is also
# an opportunity to retire an older quarantined kernel.
self._retry_quarantined_kernels(session_id)
with self._lock:
kernel = self._kernels.get(session_id)
if kernel is None:
raise KeyError(f"No sandbox session open for {session_id!r}")
return kernel
@staticmethod
def _validate_session_id(session_id: str) -> None:
"""Reject session ids that would not be a safe filesystem path component."""
if not _SESSION_ID_RE.match(session_id):
raise ValueError(f"Invalid session id {session_id!r}: expected [A-Za-z0-9_-]+")
# -- Lifecycle -------------------------------------------------------
def open(self, session_id: str) -> str:
"""Start a fresh kernel for ``session_id`` and prime its workspace cwd.
Idempotent under concurrency: if another thread is already creating a kernel
for this session, wait for it and reuse the result rather than POSTing a
second kernel that would orphan on the gateway.
"""
self._validate_session_id(session_id)
with self._create_cv:
while session_id in self._creating:
self._create_cv.wait()
existing = self._kernels.get(session_id)
has_quarantine = any(
owner == session_id for owner in self._quarantined_kernels.values()
)
if existing is not None and not has_quarantine:
return existing.kernel_id
self._creating.add(session_id)
try:
unresolved = self._retry_quarantined_kernels(session_id)
with self._lock:
# A replacement may already exist. It is safe to use even when
# deletion of an older exact id remains pending.
existing = self._kernels.get(session_id)
if existing is not None:
return existing.kernel_id
if unresolved:
raise RuntimeError(
f"Previous timed-out kernel for {session_id!r} could not be terminated"
)
resp = requests.post(
f"{self._base_url}/api/kernels",
headers=self._headers(),
data=json.dumps({"name": self._kernel_name}),
timeout=self._http_timeout,
)
resp.raise_for_status()
kernel_id = resp.json()["id"]
workspace = f"{_WORKSPACE_ROOT}/{session_id}"
kernel = _Kernel(kernel_id, workspace, session_id)
with self._lock:
self._kernels[session_id] = kernel
self._prime(kernel)
return kernel_id
finally:
with self._create_cv:
self._creating.discard(session_id)
self._create_cv.notify_all()
def attach(self, session_id: str) -> str:
"""Reattach to a still-running kernel for ``session_id``; open a cold one if gone."""
self._validate_session_id(session_id)
unresolved = self._retry_quarantined_kernels(session_id)
with self._lock:
existing = self._kernels.get(session_id)
if existing is not None and self._kernel_alive(existing.kernel_id):
return existing.kernel_id
if existing is not None:
with self._lock:
self._kernels.pop(session_id, None)
if unresolved:
raise RuntimeError(
f"Previous timed-out kernel for {session_id!r} could not be terminated"
)
logger.warning("Re-attaching session %s to a cold kernel; previous state is lost", session_id)
return self.open(session_id)
def close(self, session_id: str) -> None:
"""Sweep the session workspace (best-effort) then delete its kernel and registry entry."""
with self._lock:
kernel = self._kernels.pop(session_id, None)
if kernel is None:
return
self._cleanup_workspace(kernel)
self._delete_kernel(kernel.kernel_id)
def _cleanup_workspace(self, kernel: _Kernel) -> None:
"""Best-effort rmtree of the per-session workspace while the kernel is still alive."""
if not kernel.initialized:
return
code = "import shutil as _sh\n" f"_sh.rmtree({kernel.workspace!r}, ignore_errors=True)\n"
try:
self._run(kernel, code, self._http_timeout)
except Exception: # noqa: BLE001 - teardown is best-effort and must never raise
logger.warning("Failed to sweep workspace for kernel %s", kernel.kernel_id, exc_info=True)
def close_handle(self, session_id: str, kernel_id: str) -> None:
"""Delete the SPECIFIC kernel captured at eviction time, never a re-opened one.
When the manager evicts a session, a concurrent ``open`` of the same id may have
already started a fresh kernel; this deletes only the kernel whose id was captured
and pops the registry entry only when it still points at that same kernel, so the
new kernel survives.
"""
with self._lock:
current = self._kernels.get(session_id)
if current is not None and current.kernel_id == kernel_id:
self._kernels.pop(session_id, None)
self._delete_kernel(kernel_id)
def _delete_kernel(self, kernel_id: str) -> bool:
"""Best-effort DELETE of a gateway kernel, retrying once and never raising."""
last_error = "unknown error"
for _attempt in range(2):
try:
resp = requests.delete(
f"{self._base_url}/api/kernels/{kernel_id}",
headers=self._headers(),
timeout=self._http_timeout,
)
except requests.RequestException as exc: # teardown is best-effort
last_error = str(exc)
continue
# A missing kernel is already in the desired state.
if 200 <= resp.status_code < 300 or resp.status_code == 404:
return True
last_error = f"HTTP {resp.status_code}"
logger.warning("Failed to delete kernel %s after retry: %s", kernel_id, last_error)
return False
def _kernel_alive(self, kernel_id: str) -> bool:
try:
resp = requests.get(
f"{self._base_url}/api/kernels/{kernel_id}",
headers=self._headers(),
timeout=self._http_timeout,
)
return resp.status_code == 200
except requests.RequestException:
return False
def _interrupt(self, kernel_id: str) -> None:
"""Best-effort interrupt so a timed-out/runaway kernel becomes reusable."""
try:
requests.post(
f"{self._base_url}/api/kernels/{kernel_id}/interrupt",
headers=self._headers(),
timeout=self._http_timeout,
)
except requests.RequestException as exc:
logger.warning("Failed to interrupt kernel %s: %s", kernel_id, exc)
def _interrupt_and_drain(self, ws: websocket.WebSocket, msg_id: str, kernel_id: str) -> bool:
"""Interrupt and drain the request, returning whether matching idle was observed."""
self._interrupt(kernel_id)
drain_deadline = time.monotonic() + self._http_timeout
while time.monotonic() < drain_deadline:
try:
ws.settimeout(max(0.05, drain_deadline - time.monotonic()))
raw = ws.recv()
except (websocket.WebSocketTimeoutException, websocket.WebSocketConnectionClosedException):
return False
if not raw:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if msg.get("parent_header", {}).get("msg_id") != msg_id:
continue
msg_type = msg.get("msg_type") or msg.get("header", {}).get("msg_type")
if msg_type == "status" and msg.get("content", {}).get("execution_state") == "idle":
return True
return False
def _retry_quarantined_kernels(self, session_id: str) -> List[str]:
"""Retry pending exact-id deletes for this session; return unresolved ids."""
with self._lock:
pending = [
(kernel_id, owner)
for kernel_id, owner in self._quarantined_kernels.items()
if owner == session_id
]
unresolved: List[str] = []
for kernel_id, owner in pending:
if self._delete_kernel(kernel_id):
with self._lock:
if (
kernel_id in self._quarantined_kernels
and self._quarantined_kernels[kernel_id] == owner
):
self._quarantined_kernels.pop(kernel_id, None)
else:
unresolved.append(kernel_id)
return unresolved
def _invalidate_kernel(
self, kernel_id: str, session_id: Optional[str] = None
) -> bool:
"""Quarantine and hard-delete one exact kernel without touching a replacement."""
with self._lock:
stale_sessions = [
session_id
for session_id, kernel in self._kernels.items()
if kernel.kernel_id == kernel_id
]
owner = session_id or (stale_sessions[0] if len(stale_sessions) == 1 else None)
# Register before eviction so a failed DELETE never loses the only
# retryable reference to this runaway kernel id.
self._quarantined_kernels[kernel_id] = owner
for session_id in stale_sessions:
self._kernels.pop(session_id, None)
deleted = self._delete_kernel(kernel_id)
if deleted:
with self._lock:
self._quarantined_kernels.pop(kernel_id, None)
return deleted
def _prime(self, kernel: _Kernel) -> None:
"""Create the per-session workspace (mode 0700) and chdir the kernel into it."""
# 0700 on the root and the per-session dir is defense-in-depth only: every
# kernel runs under one shared uid here, so this is not a cross-session
# boundary (that needs distinct uids / per-session VMs -- the Daytona backend).
# A fresh kernel always starts on a clean workspace: rmtree any stale dir a
# prior kernel for the same session id left behind (else artifacts_capture
# would re-read those files every exec). Only genuine new-kernel creation
# reaches here -- open() on a live kernel returns early -- so a warm
# persist=true session is never wiped mid-computation.
setup = (
"import os as _os, shutil as _sh\n"
f"_os.makedirs({_WORKSPACE_ROOT!r}, mode=0o700, exist_ok=True)\n"
f"_os.chmod({_WORKSPACE_ROOT!r}, 0o700)\n"
f"_sh.rmtree({kernel.workspace!r}, ignore_errors=True)\n"
f"_os.makedirs({kernel.workspace!r}, mode=0o700, exist_ok=True)\n"
f"_os.chmod({kernel.workspace!r}, 0o700)\n"
f"_os.chdir({kernel.workspace!r})\n"
)
result = self._run(kernel, setup, self._default_timeout)
if not result.ok:
raise RuntimeError(f"Sandbox workspace setup failed: {result.error_value}")
kernel.initialized = True
# -- Execution -------------------------------------------------------
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
"""Run ``code`` in the session's persistent kernel; state carries across calls."""
kernel = self._get_kernel(session_id)
return self._run(kernel, code, timeout or self._default_timeout)
def _run(
self,
kernel: _Kernel,
code: str,
timeout: float,
max_output_bytes: Optional[int] = None,
) -> ExecResult:
"""Execute one ``execute_request`` over the WS channel and assemble the reply.
``max_output_bytes`` overrides the default output budget for this call only
(file-transfer execs raise it so a multi-MB base64 payload is not truncated).
"""
try:
ws = websocket.create_connection(
self._ws_url(kernel.kernel_id),
timeout=timeout,
header=self._ws_headers(),
)
except Exception as exc: # noqa: BLE001 - connect failure -> error result, never raise
return _error_result(type(exc).__name__, str(exc) or "failed to open kernel channel")
try:
msg_id = uuid.uuid4().hex
ws.send(json.dumps(self._execute_request(msg_id, code)))
return self._collect(
ws,
msg_id,
timeout,
kernel.kernel_id,
max_output_bytes,
session_id=kernel.session_id,
)
finally:
try:
ws.close()
except Exception: # noqa: BLE001 - closing a socket must not mask results
pass
def _ws_headers(self) -> List[str]:
if self._auth_token:
return [f"Authorization: token {self._auth_token}"]
return []
@staticmethod
def _execute_request(msg_id: str, code: str) -> dict:
"""Build a Jupyter ``execute_request`` wire message for the shell channel."""
return {
"header": {
"msg_id": msg_id,
"username": "docsgpt",
"session": uuid.uuid4().hex,
"msg_type": "execute_request",
"version": "5.3",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
},
"channel": "shell",
}
def _collect(
self,
ws: websocket.WebSocket,
msg_id: str,
timeout: float,
kernel_id: str,
max_output_bytes: Optional[int] = None,
session_id: Optional[str] = None,
) -> ExecResult:
"""Read iopub/shell frames until ``execute_reply``/idle, a wall-clock deadline, or a closed socket."""
effective = max_output_bytes if max_output_bytes is not None else self._max_output_bytes
result = ExecResult()
stdout_parts: List[str] = []
stderr_parts: List[str] = []
buffered = 0
truncated = False
reply_seen = False
idle_seen = False
deadline = time.monotonic() + timeout
while not (reply_seen and idle_seen):
now = time.monotonic()
remaining = deadline - now
if remaining <= 0:
self._fail(result, "TimeoutError", f"execution exceeded {timeout}s")
if not self._interrupt_and_drain(ws, msg_id, kernel_id):
self._invalidate_kernel(kernel_id, session_id)
result.runtime_invalidated = True
break
try:
ws.settimeout(remaining)
raw = ws.recv()
except websocket.WebSocketTimeoutException:
self._fail(result, "TimeoutError", f"execution exceeded {timeout}s")
if not self._interrupt_and_drain(ws, msg_id, kernel_id):
self._invalidate_kernel(kernel_id, session_id)
result.runtime_invalidated = True
break
except websocket.WebSocketConnectionClosedException:
self._fail(result, "KernelDiedError", "kernel channel closed before completion")
break
if not raw:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError as exc:
self._fail(result, "ProtocolError", f"malformed kernel frame: {exc}")
break
if msg.get("parent_header", {}).get("msg_id") != msg_id:
continue
msg_type = msg.get("msg_type") or msg.get("header", {}).get("msg_type")
content = msg.get("content", {})
if msg_type == "stream":
if not truncated:
text = content.get("text", "")
buffered += len(text.encode("utf-8", "ignore"))
if buffered > effective:
truncated = True
self._interrupt_and_drain(ws, msg_id, kernel_id) # runaway output: stop and drain
break
elif content.get("name") == "stderr":
stderr_parts.append(text)
else:
stdout_parts.append(text)
elif msg_type in ("execute_result", "display_data"):
# Rich outputs (results/display_data/plots) count against the SAME
# byte budget as streams: untrusted code can emit a huge DataFrame /
# IPython.display.HTML / many large images that the backend would
# otherwise buffer unbounded. Drop and truncate once over budget.
if not truncated:
buffered += self._rich_payload_bytes(content)
if buffered > effective:
truncated = True
self._interrupt_and_drain(ws, msg_id, kernel_id)
break
self._capture_rich(result, msg_type, content)
elif msg_type == "error":
result.status = "error"
result.exit_code = 1
result.error_name = content.get("ename")
result.error_value = content.get("evalue")
result.traceback = content.get("traceback", [])
elif msg_type == "execute_reply":
reply_seen = True
result.execution_count = content.get("execution_count")
if content.get("status") == "error":
result.status = "error"
result.exit_code = result.exit_code or 1
result.error_name = result.error_name or content.get("ename")
result.error_value = result.error_value or content.get("evalue")
if not result.traceback:
result.traceback = content.get("traceback", [])
elif msg_type == "status":
if content.get("execution_state") == "idle":
idle_seen = True
if truncated:
stderr_parts.append(f"\n[output truncated at {effective} bytes]")
result.truncated = True # surface the cut without flipping status off "ok"
result.stdout = "".join(stdout_parts)
result.stderr = "".join(stderr_parts)
return result
@staticmethod
def _fail(result: ExecResult, name: str, value: str) -> None:
"""Mark ``result`` as a failed exec with the given error name/value."""
result.status = "error"
result.error_name = name
result.error_value = value
result.exit_code = -1
@staticmethod
def _rich_payload_bytes(content: dict) -> int:
"""Approximate the serialized byte size of a rich-output bundle's data payloads."""
total = 0
for value in (content.get("data") or {}).values():
if isinstance(value, str):
total += len(value.encode("utf-8", "ignore"))
else:
try:
total += len(json.dumps(value, default=str).encode("utf-8", "ignore"))
except Exception:
total += len(str(value).encode("utf-8", "ignore"))
return total
@staticmethod
def _capture_rich(result: ExecResult, msg_type: str, content: dict) -> None:
"""Sort a rich output into results/display_data and pull out any image plots."""
data = content.get("data", {}) or {}
metadata = content.get("metadata", {}) or {}
bundle = DisplayData(data=data, metadata=metadata)
if msg_type == "execute_result":
result.results.append(bundle)
else:
result.display_data.append(bundle)
for mime, payload in data.items():
if mime.startswith("image/"):
result.plots.append(Plot(format=mime.split("/", 1)[1], content_base64=payload))
# -- File transfer ---------------------------------------------------
def _file_transfer_budget(self) -> int:
"""Output budget for a file-transfer exec: max file bytes inflated by base64 plus marker slack."""
return self._max_file_bytes * 4 // 3 + 4096
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
"""Decode ``data`` inside the kernel and write it under the session workspace.
The upload is chunked so no single ``execute_request`` exceeds the gateway's
websocket message-size cap: the first chunk creates/truncates the file (``wb``)
and each later chunk appends (``ab``). Every chunk program re-resolves the path
-- kernel globals are not relied on to survive between execs.
"""
kernel = self._get_kernel(session_id)
offset = 0
first = True
# Loop at least once so an empty file is still created (a single wb write of b"").
while first or offset < len(data):
chunk = data[offset:offset + self._PUT_CHUNK_BYTES]
encoded = base64.b64encode(chunk).decode("ascii")
if first:
body = (
f"_p = _resolve({kernel.workspace!r}, {dest_path!r})\n"
"_os.makedirs(_os.path.dirname(_p) or '.', exist_ok=True)\n"
f"_f = open(_p, 'wb'); _f.write(_b64.b64decode({encoded!r})); _f.close()\n"
)
else:
body = (
f"_p = _resolve({kernel.workspace!r}, {dest_path!r})\n"
f"_f = open(_p, 'ab'); _f.write(_b64.b64decode({encoded!r})); _f.close()\n"
)
code = "import base64 as _b64, os as _os\n" + _CONTAINMENT_SNIPPET + body
result = self._run(kernel, code, self._default_timeout)
if not result.ok:
raise IOError(f"put_file failed: {result.error_value}")
offset += self._PUT_CHUNK_BYTES
first = False
def get_file(self, session_id: str, path: str) -> bytes:
"""Read ``path`` inside the kernel and stream its base64 (with a length tag) over stdout."""
kernel = self._get_kernel(session_id)
code = (
"import base64 as _b64, hashlib as _hl, os as _os\n"
+ _CONTAINMENT_SNIPPET
+ f"_p = _resolve({kernel.workspace!r}, {path!r})\n"
f"_sz = _os.path.getsize(_p)\n"
f"if _sz > {self._max_file_bytes}:\n"
f" raise ValueError('file too large: %d > {self._max_file_bytes} bytes' % _sz)\n"
"_d = open(_p, 'rb').read()\n"
"_h = _hl.sha256(_d).hexdigest()\n"
f"print({_FILE_BEGIN!r} + str(len(_d)) + ':' + _h + ':'"
f" + _b64.b64encode(_d).decode('ascii') + {_FILE_END!r})\n"
)
result = self._run(kernel, code, self._default_timeout, max_output_bytes=self._file_transfer_budget())
if not result.ok:
raise IOError(f"get_file failed: {result.error_value}")
out = result.stdout
start = out.find(_FILE_BEGIN)
end = out.find(_FILE_END)
if start == -1 or end == -1:
raise IOError(f"get_file produced no payload for {path!r}")
payload = out[start + len(_FILE_BEGIN):end]
expected_len_s, expected_sha, encoded = payload.split(":", 2)
decoded = base64.b64decode(encoded)
if len(decoded) != int(expected_len_s) or hashlib.sha256(decoded).hexdigest() != expected_sha:
raise IOError(f"get_file integrity check failed for {path!r} (payload truncated)")
return decoded
def list_files(self, session_id: str) -> List[str]:
"""Walk the session workspace inside the kernel and return relative paths."""
kernel = self._get_kernel(session_id)
code = (
"import os as _os, json as _json\n"
f"_root = {kernel.workspace!r}\n"
"_out = []\n"
"for _dp, _dn, _fn in _os.walk(_root):\n"
" for _name in _fn:\n"
" _out.append(_os.path.relpath(_os.path.join(_dp, _name), _root))\n"
f"print({_FILE_BEGIN!r} + _json.dumps(_out) + {_FILE_END!r})\n"
)
result = self._run(kernel, code, self._default_timeout, max_output_bytes=self._file_transfer_budget())
if not result.ok:
raise IOError(f"list_files failed: {result.error_value}")
out = result.stdout
start = out.find(_FILE_BEGIN)
end = out.find(_FILE_END)
if start == -1 or end == -1:
return []
return json.loads(out[start + len(_FILE_BEGIN):end])
def _error_result(name: str, value: str) -> ExecResult:
"""Build a failed ExecResult carrying the given error name/value."""
return ExecResult(status="error", error_name=name, error_value=value, exit_code=-1)