Files
nextcraft/apps/ai-service/scripts/sandbox-agent.py
T
CIAgent dc00a4f8f5 chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy]
check_untyped_defs + no_implicit_optional + warn_unused_ignores +
warn_redundant_casts + warn_unreachable, no overrides (every dep ships
py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not
excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found
by the gate (LLMProvider protocol async-generator shape, SessionStore
protocol sync/async mismatch, structured_reply genericity, get_args
migrations, sandbox-agent Optional guard honesty, proctor test arg-order,
dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions
scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead
removed, 15 scoped (warn_unused_ignores self-eliminates the rest).
Wired: scripts/typecheck.sh + package.json + turbo task + root
ai:typecheck — pnpm typecheck now 5 tsc + mypy.

Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class
(Tooltip formatter ValueType typing); 2 one-line inference fixes on the
2 chart files; no rollback needed (well inside the G-106 timebox).
Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248
with chart SSR markers verified in exported employer + dashboard pages.

Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain,
all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight +
rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via
c8→test-exclude (D-052-binding coverage tool — swapping it is out of
budget). Python: no EOL packages; zero-warning regime under
filterwarnings=error is the machine-checked proof.

Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still
exactly 2.

---ci---
phase: 5
milestone: v0.7
status: execute
---/ci---
2026-09-27 10:08:07 +00:00

823 lines
32 KiB
Python

#!/usr/bin/env python3
"""sandbox-agent — stdlib-only in-sandbox telemetry capture agent (REQ-3-003).
D-031: this file is copied into the sandbox namespace and runs against the
system Python — no third-party packages are importable there, so this module
depends on the standard library ONLY (the test suite enforces this with an
AST scan of the file).
What it does:
* wraps a non-interactive `/bin/sh` REPL: each stdin line is executed via
`sh -c` inside the workspace and reported as `stdin` -> `command` ->
`stdout` -> `run_result`/`test_result` events;
* polls the workspace tree (~250 ms) and emits `file_diff` events
(created/modified/deleted with unified diffs) plus periodic `activity`
heartbeats;
* streams events to ai-service as TelemetryEvent-shaped JSON frames over a
raw-socket RFC 6455 WebSocket client (no `websockets` package exists in
the namespace — the client handshake + frame codec is implemented here);
* at-least-once delivery (D-026): every event is appended to an fsync'd
JSONL spool file inside the workdir BEFORE any send attempt; on
disconnect the spool grows; after reconnect (exponential backoff) the
spool is flushed oldest-first. The server dedups on (learner, task, seq)
so replayed duplicates are harmless — loss is not tolerated.
Configured entirely through env baked at spawn time:
NC_LEARNER_ID / NC_TASK_ID / NC_INGEST_URL / NC_SANDBOX_ID (required)
NC_WORKSPACE workspace root to watch/run in (default: cwd)
NC_SPOOL spool path (default: <workspace>/.nc-agent/spool.jsonl)
NC_POLL_INTERVAL_S / NC_ACTIVITY_INTERVAL_S / NC_COMMAND_TIMEOUT_S
NC_BACKOFF_BASE_S / NC_BACKOFF_MAX_S (optional knobs)
Sequencing survives process restarts (incl. SIGKILL): on boot the spool is
replayed into the pending queue and `seq` resumes at max(spooled seq) + 1.
"""
from __future__ import annotations
import base64
import difflib
import hashlib
import json
import os
import secrets
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import urllib.parse
from collections import deque
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
_EVENT_KINDS = frozenset(
{"command", "file_diff", "run_result", "test_result", "activity", "stdin", "stdout"}
)
_AGENT_DIR_PREFIX = ".nc-" # agent-private paths (spool) are excluded from watching
_MAX_DIFF_BYTES = 64 * 1024 # files larger than this are reported truncated, no diff
_MAX_OUTPUT_CHARS = 64 * 1024 # captured stdout/stderr tail cap per command
_HANDSHAKE_MAX_BYTES = 64 * 1024
# --------------------------------------------------------------------------- config
@dataclass(frozen=True)
class AgentConfig:
"""Runtime configuration, normally built from `NC_*` env baked at spawn."""
learner_id: str
task_id: str
ingest_url: str
sandbox_id: str
workspace: Path
spool_path: Path
poll_interval_s: float = 0.25
activity_interval_s: float = 5.0
command_timeout_s: float = 30.0
backoff_base_s: float = 0.25
backoff_max_s: float = 8.0
#: Spool bound (G-14): explicit cap where none existed. Worst case
#: ~64KB/line (diff cap) * SPOOL_MAX_LINES must stay well under the
#: G-2 512MB workdir sweep: 4096 * 64KB = 256MB (half the budget).
spool_max_lines: int = 4096
def __post_init__(self) -> None:
for name in ("learner_id", "task_id", "ingest_url", "sandbox_id"):
if not getattr(self, name):
raise ValueError(f"missing required config: NC_{name.upper()}")
@classmethod
def from_env(cls, env: Mapping[str, str] | None = None) -> AgentConfig:
src = os.environ if env is None else env
workspace = Path(src.get("NC_WORKSPACE") or os.getcwd()).resolve()
return cls(
learner_id=src.get("NC_LEARNER_ID", ""),
task_id=src.get("NC_TASK_ID", ""),
ingest_url=src.get("NC_INGEST_URL", ""),
sandbox_id=src.get("NC_SANDBOX_ID", ""),
workspace=workspace,
spool_path=Path(
src.get("NC_SPOOL") or (workspace / ".nc-agent" / "spool.jsonl")
),
poll_interval_s=float(src.get("NC_POLL_INTERVAL_S", "0.25")),
activity_interval_s=float(src.get("NC_ACTIVITY_INTERVAL_S", "5.0")),
command_timeout_s=float(src.get("NC_COMMAND_TIMEOUT_S", "30.0")),
backoff_base_s=float(src.get("NC_BACKOFF_BASE_S", "0.25")),
backoff_max_s=float(src.get("NC_BACKOFF_MAX_S", "8.0")),
)
# --------------------------------------------------------------------------- spool
class Spool:
"""Append-only JSONL spool with per-append fsync (survives SIGKILL).
`rewrite` swaps in a compacted file atomically (tmp file + os.replace).
Lines are stored without trailing newlines in memory, one per line on disk.
"""
def __init__(self, path: Path) -> None:
self._path = path
path.parent.mkdir(parents=True, exist_ok=True)
@property
def path(self) -> Path:
return self._path
def append(self, line: str) -> None:
with self._path.open("a", encoding="utf-8") as fh:
fh.write(line + "\n")
fh.flush()
os.fsync(fh.fileno())
def read_all(self) -> list[str]:
if not self._path.exists():
return []
with self._path.open("r", encoding="utf-8") as fh:
return [line.rstrip("\n") for line in fh if line.strip()]
def rewrite(self, lines: list[str]) -> None:
tmp = self._path.with_name(self._path.name + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
for line in lines:
fh.write(line + "\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, self._path)
def _line_seq(line: str) -> int | None:
"""Best-effort seq extraction from a spool line (None when unparseable).
The event's seq is a top-level wire field (`_next_event`). Used only for
ack trimming; an unparseable line is retained (never dropped by the ack
path — the overflow bound is the only dropper).
"""
try:
seq = json.loads(line).get("seq")
return seq if isinstance(seq, int) else None
except (ValueError, AttributeError):
return None
# --------------------------------------------------------------- websocket codec
def _encode_frame(opcode: int, payload: bytes) -> bytes:
"""RFC 6455 client frame: FIN set, always masked (servers require it)."""
header = bytearray([0x80 | opcode])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack("!H", n)
else:
header.append(0x80 | 127)
header += struct.pack("!Q", n)
mask = secrets.token_bytes(4)
header += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return bytes(header) + masked
class WsConnection:
"""Minimal blocking RFC 6455 client over a raw socket (stdlib only)."""
def __init__(self, sock: socket.socket) -> None:
self._sock = sock
self._write_lock = threading.Lock()
@classmethod
def connect(cls, url: str, timeout_s: float = 5.0) -> WsConnection:
parts = urllib.parse.urlsplit(url)
if parts.scheme not in ("ws", "wss"):
raise ValueError(f"unsupported scheme in NC_INGEST_URL: {parts.scheme!r}")
host = parts.hostname or "localhost"
port = parts.port or (443 if parts.scheme == "wss" else 80)
path = parts.path or "/"
if parts.query:
path += "?" + parts.query
sock = socket.create_connection((host, port), timeout=timeout_s)
try:
if parts.scheme == "wss":
sock = ssl.create_default_context().wrap_socket(
sock, server_hostname=host
)
key = base64.b64encode(secrets.token_bytes(16)).decode("ascii")
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
)
sock.sendall(request.encode("ascii"))
response = cls._read_http_response(sock)
cls._validate_handshake(response, key)
except BaseException:
# P3 (Task 3-2-02): a failed connect after the TCP dial leaves
# the raw socket with no WsConnection owner — the supervisor's
# except block re-raised before one existed, so every failed
# reconnect leaked the fd (unclosed-socket ResourceWarnings;
# fd exhaustion over a long outage). Close it HERE and re-raise
# the same exception: the backoff/reconnect logic is unchanged.
try:
sock.close()
except OSError:
pass
raise
return cls(sock)
@staticmethod
def _read_http_response(sock: socket.socket) -> bytes:
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
raise ConnectionError("server closed during WebSocket handshake")
buf += chunk
if len(buf) > _HANDSHAKE_MAX_BYTES:
raise ConnectionError("handshake response exceeded size cap")
return buf.split(b"\r\n\r\n", 1)[0]
@staticmethod
def _validate_handshake(response: bytes, key: str) -> None:
head = response.decode("latin-1")
lines = head.split("\r\n")
if not lines or " 101" not in lines[0]:
raise ConnectionError(f"handshake rejected: {lines[0] if lines else '<empty>'}")
headers = {}
for line in lines[1:]:
if ":" in line:
name, _, value = line.partition(":")
headers[name.strip().lower()] = value.strip()
expect = base64.b64encode(
hashlib.sha1((key + _WS_GUID).encode("ascii")).digest()
).decode("ascii")
if headers.get("sec-websocket-accept") != expect:
raise ConnectionError("bad Sec-WebSocket-Accept in handshake response")
# -- send ------------------------------------------------------------
def send_text(self, text: str) -> None:
with self._write_lock:
self._sock.sendall(_encode_frame(0x1, text.encode("utf-8")))
def _send_frame(self, opcode: int, payload: bytes) -> None:
with self._write_lock:
self._sock.sendall(_encode_frame(opcode, payload))
# -- receive ---------------------------------------------------------
def recv_message(self, timeout_s: float) -> tuple[int, bytes] | None:
"""Return (opcode, payload) for a data/close frame, or None on timeout.
Ping frames are answered with pong internally and never surfaced;
pongs are swallowed. Fragmented messages are reassembled. Raises
ConnectionError/OSError when the socket breaks.
"""
deadline = time.monotonic() + timeout_s
fragments = bytearray()
frag_opcode = 0
while True:
frame = self._recv_one_frame(deadline)
if frame is None:
return None
fin, opcode, payload = frame
if opcode == 0x9: # ping
self._send_frame(0xA, payload)
continue
if opcode == 0xA: # pong
continue
if opcode == 0x0: # continuation
fragments += payload
else:
fragments = bytearray(payload)
frag_opcode = opcode
if fin:
return frag_opcode, bytes(fragments)
def _recv_one_frame(self, deadline: float) -> tuple[bool, int, bytes] | None:
header = self._read_exact(2, deadline)
if header is None:
return None
b0, b1 = header[0], header[1]
fin = bool(b0 & 0x80)
opcode = b0 & 0x0F
length = b1 & 0x7F
if length == 126:
ext = self._read_exact(2, deadline)
if ext is None:
return None
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = self._read_exact(8, deadline)
if ext is None:
return None
length = struct.unpack("!Q", ext)[0]
mask = self._read_exact(4, deadline) if (b1 & 0x80) else b""
if mask is None:
return None
payload = self._read_exact(length, deadline) if length else b""
if payload is None:
return None
if mask:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return fin, opcode, payload
def _read_exact(self, n: int, deadline: float) -> bytes | None:
buf = bytearray()
while len(buf) < n:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
self._sock.settimeout(remaining)
try:
chunk = self._sock.recv(n - len(buf))
except TimeoutError:
return None
if not chunk:
raise ConnectionError("peer closed the WebSocket connection")
buf += chunk
return bytes(buf)
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
# --------------------------------------------------------------------------- agent
class Agent:
"""Wires capture (shell + workspace watcher) to the framed event stream."""
def __init__(self, config: AgentConfig) -> None:
self.config = config
self._spool = Spool(config.spool_path)
self._pending: deque[str] = deque()
self._seq = 0
# RLock (D-3 verifier fix): emit() holds this across sends; a send
# failure drops the conn, and _drop_conn -> replay_margin re-enters
# the same lock. A plain Lock deadlocked the emitting thread there.
self._emit_lock = threading.RLock() # serializes seq + spool + flush
self._conn_lock = threading.Lock() # guards _conn swaps
self._conn: WsConnection | None = None
self._last_sent: str | None = None # one-line replay margin, see below
self._stop = threading.Event()
self._threads: list[threading.Thread] = []
self._baseline: dict[str, tuple[int, int, str | None]] = {}
# Spool bound (G-14): explicit cap where none existed. Worst case
# ~64KB/line (diff cap) x SPOOL_MAX_LINES must stay well under the
# G-2 512MB workdir sweep; overflow drops OLDEST lines with a
# counter — by-design gap creation, so the trace goes ungradable
# (G-4) instead of silently truncated-but-gradable.
self._dropped_overflow = 0
self._enforce_spool_bound_locked()
self._resume_from_spool()
# -- durability ------------------------------------------------------
def _resume_from_spool(self) -> None:
highest = -1
for line in self._spool.read_all():
self._pending.append(line)
try:
seq = int(json.loads(line).get("seq", -1))
except (ValueError, AttributeError):
continue
highest = max(highest, seq)
self._seq = highest + 1
# -- event construction ---------------------------------------------
def _wire_frame(self, spooled_line: str) -> str:
"""Spool format -> wire format: strip URL-owned identity fields.
The spool keeps full events (local durability + restart recovery).
The ingest endpoint binds identity at the WS handshake (query params)
and rejects frames carrying learner_id/task_id (`extra="forbid"`
anti-spoofing), so the wire frame carries only seq/kind/payload/ts.
"""
import json as _json
full = _json.loads(spooled_line)
wire = {
k: full[k]
for k in ("seq", "kind", "payload", "ts")
}
if full.get("sandbox_id"):
wire["sandbox_id"] = full["sandbox_id"]
return _json.dumps(wire)
def _next_event(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
if kind not in _EVENT_KINDS:
raise ValueError(f"unknown event kind: {kind!r}")
event = {
"learner_id": self.config.learner_id,
"task_id": self.config.task_id,
"seq": self._seq,
"kind": kind,
"payload": payload,
"ts": datetime.now(UTC).isoformat(),
"sandbox_id": self.config.sandbox_id,
}
self._seq += 1
return event
# -- emission / flush (D-026) ----------------------------------------
def emit(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Spool-then-send. Never blocks on reconnect; loss is impossible."""
with self._emit_lock:
line = json.dumps(self._next_event(kind, payload))
self._spool.append(line) # durable BEFORE any send attempt
self._pending.append(line)
self._enforce_spool_bound_locked()
self._flush_locked()
return json.loads(line)
def _enforce_spool_bound_locked(self) -> None:
"""Drop OLDEST spooled lines past `spool_max_lines` (G-14).
Overflow is by-design gap creation: the dropped seqs become
permanent gaps server-side, the gap path flags the trace, and the
grader refuses it (G-4) — never a silently-truncated-but-gradable
trace. Caller must hold `_emit_lock`. `_dropped_overflow` is the
observable counter (surfaced in the final stop status event).
"""
lines = self._spool.read_all()
overflow = len(lines) - self.config.spool_max_lines
if overflow <= 0:
return
self._dropped_overflow += overflow
self._spool.rewrite(lines[overflow:])
# Pending may reference dropped lines; they replay as no-ops (server
# dedup) but trimming them keeps the replay window honest.
dropped = set(lines[:overflow])
self._pending = deque(ln for ln in self._pending if ln not in dropped)
def _flush_locked(self) -> None:
conn = self._current_conn()
while self._pending and conn is not None:
line = self._pending[0]
try:
conn.send_text(self._wire_frame(line))
except (ConnectionError, OSError):
self._drop_conn()
return
self._pending.popleft()
self._last_sent = line
# D-045/D-1 (verifier fix): the spool is NEVER compacted below the
# unacked window. A send into a silently-dead socket "succeeds" at
# TCP level — those lines may be lost in flight — so they stay in
# the spool until the server's seq_ack proves durable storage
# (trim_to_ack is the ONLY spool shrinker besides the overflow
# bound). Replays are harmless: the server dedups on
# (learner, task, seq).
def replay_margin(self) -> None:
"""Requeue every unacked spooled line after a detected disconnect.
D-045/D-1 (verifier fix): the pre-ack one-line margin could not cover
a multi-frame in-flight window — a burst accepted by a dying socket
popped N lines from pending while the spool had been compacted to the
last one, permanently losing lines 1..N-1. The spool now retains
everything unacked, so replay requeues the full unacked window;
server-side dedup absorbs the duplicates.
"""
with self._emit_lock:
if self._pending:
return # mid-flush caller holds the pending queue intact
self._pending = deque(self._spool.read_all())
self._last_sent = None
def trim_to_ack(self, ack_seq: int) -> None:
"""Drop every spooled/pending line with seq <= ack_seq (D-045).
Advisory server hint: `seq_ack` carries the durable latest_seq, so
everything up to and including it is stored server-side and dedup
absorbs nothing on replay. Runs under `_emit_lock` — ordered against
concurrent emit()/flush, and the rewrite is atomic (Spool.rewrite).
Lines without a parseable seq are retained; the overflow bound is
the only dropper of unparseable lines.
"""
with self._emit_lock:
spooled = self._spool.read_all()
kept_pending = [
ln for ln in self._pending if (s := _line_seq(ln)) is None or s > ack_seq
]
kept_spooled = [
ln for ln in spooled if (s := _line_seq(ln)) is None or s > ack_seq
]
if len(kept_pending) != len(self._pending) or len(kept_spooled) != len(spooled):
self._pending = deque(kept_pending)
self._spool.rewrite(kept_spooled)
if self._last_sent is not None:
s = _line_seq(self._last_sent)
if s is not None and s <= ack_seq:
self._last_sent = None
# -- connection supervision ------------------------------------------
def _current_conn(self) -> WsConnection | None:
with self._conn_lock:
return self._conn
def _set_conn(self, conn: WsConnection | None) -> None:
with self._conn_lock:
self._conn = conn
def _drop_conn(self) -> None:
conn = self._current_conn()
self._set_conn(None)
if conn is not None:
conn.close()
self.replay_margin()
def is_connected(self) -> bool:
return self._current_conn() is not None
def wait_connected(self, timeout_s: float) -> bool:
return self._wait_for(lambda: self.is_connected(), timeout_s)
def wait_disconnected(self, timeout_s: float) -> bool:
return self._wait_for(lambda: not self.is_connected(), timeout_s)
def _wait_for(self, pred: Any, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if pred():
return True
time.sleep(0.02)
return pred()
def _supervisor_loop(self) -> None:
"""Maintain the WS connection: connect, flush backlog, read, backoff."""
backoff = self.config.backoff_base_s
while not self._stop.is_set():
# P5: explicit Optional — the else-branch read can observe None
# (a concurrent _drop_conn between the check and the read), so
# the `conn is None` guard below is genuinely reachable.
conn: WsConnection | None
if self._current_conn() is None:
try:
conn = WsConnection.connect(self.config.ingest_url)
except (ConnectionError, OSError, ValueError, TimeoutError):
self._stop.wait(backoff)
backoff = min(self.config.backoff_max_s, backoff * 2)
continue
self._set_conn(conn)
self._last_sent = None
backoff = self.config.backoff_base_s
with self._emit_lock: # ordered against concurrent emit()s
self._flush_locked()
else:
conn = self._current_conn()
if conn is None:
continue
try:
frame = conn.recv_message(timeout_s=1.0)
except (ConnectionError, OSError):
self._drop_conn()
continue
if frame is None:
continue
opcode, payload = frame
if opcode == 0x1: # server text frame — parse advisory envelopes
self._handle_server_text(payload)
continue
if opcode == 0x8: # server close frame
self._drop_conn()
def _handle_server_text(self, payload: bytes) -> None:
"""Consume server->agent envelopes (advisory; never fatal).
`seq_ack` (D-045): the server's durable latest_seq — trims the
spool/pending to `seq > ack`, bounding the replay margin to the
in-flight window (REQ-5-007). Unknown/malformed frames are ignored:
acks are hints; gap detection and flood semantics stay authoritative
server-side.
"""
try:
envelope = json.loads(payload.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return
if not isinstance(envelope, dict):
return
if envelope.get("type") == "seq_ack":
ack_seq = envelope.get("seq")
if isinstance(ack_seq, int) and ack_seq >= 0:
self.trim_to_ack(ack_seq)
# -- workspace watcher ------------------------------------------------
def _snapshot_workspace(self) -> dict[str, tuple[int, int, str | None]]:
"""Map rel path -> (mtime_ns, size, text-or-None-if-too-large)."""
snap: dict[str, tuple[int, int, str | None]] = {}
root = self.config.workspace
if not root.is_dir():
return snap
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(
d for d in dirnames if not d.startswith(_AGENT_DIR_PREFIX)
)
for name in sorted(filenames):
if name.startswith(_AGENT_DIR_PREFIX):
continue
path = Path(dirpath) / name
try:
st = path.stat()
except OSError:
continue
rel = path.relative_to(root).as_posix()
text: str | None = None
if st.st_size <= _MAX_DIFF_BYTES:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
pass
snap[rel] = (st.st_mtime_ns, st.st_size, text)
return snap
def _file_diff_payload(self, rel: str, change: str, old: str | None, new: str | None) -> dict:
payload: dict[str, Any] = {"path": rel, "change": change}
if old is None and new is None:
payload["truncated"] = True
return payload
diff = "".join(
difflib.unified_diff(
(old or "").splitlines(keepends=True),
(new or "").splitlines(keepends=True),
fromfile=f"a/{rel}",
tofile=f"b/{rel}",
)
)
payload["diff"] = diff
payload["size"] = len(new or "")
return payload
def _watcher_loop(self) -> None:
# Baseline is taken in start() before it returns, so any change made
# after start() completes is guaranteed to be observed.
baseline = self._baseline
last_heartbeat = time.monotonic()
while not self._stop.wait(self.config.poll_interval_s):
current = self._snapshot_workspace()
for rel in sorted(current.keys() | baseline.keys()):
if rel not in baseline and rel in current:
self.emit(
"file_diff",
self._file_diff_payload(rel, "created", None, current[rel][2]),
)
elif rel in baseline and rel not in current:
self.emit(
"file_diff",
self._file_diff_payload(rel, "deleted", baseline[rel][2], None),
)
else:
old_stat, new_stat = baseline[rel], current[rel]
if old_stat[:2] != new_stat[:2] and old_stat[2] != new_stat[2]:
self.emit(
"file_diff",
self._file_diff_payload(
rel, "modified", old_stat[2], new_stat[2]
),
)
baseline = current
if time.monotonic() - last_heartbeat >= self.config.activity_interval_s:
self.emit("activity", {"state": "idle", "spooled": len(self._pending)})
last_heartbeat = time.monotonic()
# -- shell wrapper ------------------------------------------------------
@staticmethod
def _is_test_command(cmd: str) -> bool:
return "test" in cmd.lower()
def run_command(self, line: str) -> dict[str, Any] | None:
"""Run one REPL line; emits stdin/command/stdout/run|test_result."""
line = line.strip()
if not line:
return None
self.emit("stdin", {"line": line})
self.emit("activity", {"state": "command", "spooled": len(self._pending)})
self.emit("command", {"cmd": line})
started = time.monotonic()
timed_out = False
exit_code: int | None = None
out: str | bytes = ""
err: str | bytes = ""
try:
proc = subprocess.run(
["sh", "-c", line],
cwd=self.config.workspace,
capture_output=True,
timeout=self.config.command_timeout_s,
text=True,
errors="replace",
)
exit_code, out, err = proc.returncode, proc.stdout, proc.stderr
except subprocess.TimeoutExpired as exc:
timed_out = True
# TimeoutExpired output attrs are always bytes (even in text mode).
out = exc.stdout or b""
err = exc.stderr or b""
duration = time.monotonic() - started
for stream, data in (("stdout", out), ("stderr", err)):
if isinstance(data, bytes):
data = data.decode(errors="replace")
if data:
self.emit("stdout", {"stream": stream, "data": data[-_MAX_OUTPUT_CHARS:]})
kind = "test_result" if self._is_test_command(line) else "run_result"
result = self.emit(
kind,
{
"cmd": line,
"exit_code": exit_code,
"duration_s": round(duration, 6),
"timed_out": timed_out,
},
)
self.emit("activity", {"state": "idle", "spooled": len(self._pending)})
return result
# -- lifecycle ----------------------------------------------------------
def start(self) -> None:
self.config.workspace.mkdir(parents=True, exist_ok=True)
self._baseline = self._snapshot_workspace()
self.emit("activity", {"state": "starting", "spooled": len(self._pending)})
self._threads = [
threading.Thread(target=self._supervisor_loop, daemon=True, name="nc-ws"),
threading.Thread(target=self._watcher_loop, daemon=True, name="nc-watch"),
]
for thread in self._threads:
thread.start()
def stop(self) -> None:
if self._stop.is_set():
return
try:
self.emit(
"activity",
{
"state": "stopped",
"spooled": len(self._pending),
"dropped_overflow": self._dropped_overflow,
},
)
finally:
self._stop.set()
self._drop_conn()
for thread in self._threads:
thread.join(timeout=3)
for thread in self._threads:
thread.join(timeout=3)
with self._emit_lock:
self._spool.rewrite(list(self._pending))
def main() -> int:
try:
config = AgentConfig.from_env()
except ValueError as exc:
print(f"sandbox-agent: {exc}", file=sys.stderr)
return 2
agent = Agent(config)
agent.start()
try:
# REPL mode: each stdin line is executed and reported (interactive use).
# Daemon mode: when stdin is closed/absent (the sandbox backend spawns
# the agent with stdin=DEVNULL), keep streaming workspace diffs +
# activity until SIGTERM/SIGINT so the agent's lifecycle is tied to
# the sandbox (destroy() reaps it) rather than to stdin EOF.
if sys.stdin is None or sys.stdin.closed: # pragma: no cover - defensive
agent._stop.wait()
else:
line = sys.stdin.readline()
while line:
agent.run_command(line)
line = sys.stdin.readline()
if not agent._stop.is_set() and not sys.stdin.isatty():
# EOF on a pipe (DEVNULL): daemonize — watch + stream until killed.
import signal
signal.signal(signal.SIGTERM, lambda *_: agent._stop.set())
agent._stop.wait()
except KeyboardInterrupt:
pass
finally:
agent.stop()
return 0
if __name__ == "__main__":
raise SystemExit(main())