26b4a5be60
Task 2-3-01: create(learner_id, task_id) — telemetry sandboxes run a persistent
helper/inner namespace topology (offline inner ns; agent joins mount ns only and stays
online to reach the loopback ingest). Capture agent copied into the workspace (visible
in-ns at the bind), launched via sh -c with in-ns absolute paths (host cwd invalid after
the nsenter mount swap), stdin=DEVNULL daemonizes the agent (lifecycle tied to sandbox:
destroy reaps agent -> inner -> helper). Wire contract: frames strip URL-owned identity
(ingest extra=forbid anti-spoofing); spool keeps full events.
E2E test (real uvicorn on ephemeral port): exec in a live namespace sandbox -> events
arrive at WS ingest -> SQLite, ordered, sandbox-scoped. Pure-shell path (task_id=None)
asserts no capture agent. Full suite 216 green; ruff clean.
---ci---
phase: 2
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-003], partial: []}
---/ci---
692 lines
26 KiB
Python
692 lines
26 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
|
|
|
|
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)
|
|
|
|
|
|
# --------------------------------------------------------------- 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)
|
|
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)
|
|
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
|
|
self._emit_lock = threading.Lock() # 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]] = {}
|
|
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._flush_locked()
|
|
return json.loads(line)
|
|
|
|
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 # kept until a later send proves delivery
|
|
if not self._pending and self._last_sent is not None:
|
|
# Compact, but retain the most recently sent line: a send into a
|
|
# silently-dead socket "succeeds" once at TCP level, so the last
|
|
# line is only confirmed-sent once a later write works. Retention
|
|
# is cheap; the server dedups on (learner, task, seq).
|
|
self._spool.rewrite([self._last_sent])
|
|
|
|
def replay_margin(self) -> None:
|
|
"""Requeue the last-sent line after a detected disconnect."""
|
|
with self._emit_lock:
|
|
if self._last_sent is not None and (
|
|
not self._pending or self._pending[0] != self._last_sent
|
|
):
|
|
self._pending.appendleft(self._last_sent)
|
|
self._spool.rewrite(list(self._pending))
|
|
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():
|
|
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 == 0x8: # server close frame
|
|
self._drop_conn()
|
|
|
|
# -- 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)})
|
|
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() # noqa: SLF001 - daemon block
|
|
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(): # noqa: SLF001
|
|
# EOF on a pipe (DEVNULL): daemonize — watch + stream until killed.
|
|
import signal
|
|
|
|
signal.signal(signal.SIGTERM, lambda *_: agent._stop.set()) # noqa: SLF001
|
|
agent._stop.wait() # noqa: SLF001
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
agent.stop()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|