Zeren Wang a06a6fed7e
feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2) (#5109)
* feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2)

PR4 of RFC #4651: check lead-supplied acceptance_criteria in code when a
subagent completes, so objectively checkable requirements can never be
silently passed by a self-report.

- subagents/acceptance_checks.py: deterministic leaf families —
  file:<path> exists|non-empty and file_written:<path> read through
  read_current_file_content scoped to the shared thread workspace; the
  read uses the sandbox-native virtual path form (the local read
  validator and provider mount tables resolve /mnt/user-data/... paths,
  not host paths); the scope decision canonicalizes with realpath on the
  local sandbox so workspace symlinks cannot escape into uploads; a
  remote provider's "Error: ..." return string is normalized to a
  failed check (provider-typed via is_local_sandbox); a
  UnicodeDecodeError marks a binary deliverable as existing and
  non-empty; out-of-scope paths degrade to UNVERIFIED.
  tests_passed:<command> anchors to a matching recorded bash execution
  with status=success and a test-summary shape; matching is
  shell-structure aware with control-flow attribution (span must end at
  the last segment with provable execution), negating-option values are
  ineligible evidence and a target negated anywhere in the command
  degrades the match, extra flags must be selection-preserving, extra
  positionals widen only after a path-scoped criterion, truncated
  commands degrade via command_truncated, the summary shape is read
  only from output attributable to the matched segment (preceding
  segments provably silent by invocation form), and pass shapes require
  a nonzero passed count. Criterion text is neutralized with
  neutralize_untrusted_tags before storage/rendering. Anything else
  renders UNVERIFIED, never silently passed.
- executor: accumulate bounded bash command/output evidence per streamed
  chunk (merged by tool_call_id, newest-capped) so subagent
  summarization compacting earlier messages cannot erase a recorded
  execution; the recorded status is the actual shell exit status parsed
  from the output's exit marker (signed codes included; the remote
  Command exited with code N form is accepted only as the whole trimmed
  output), falling back to deerflow_tool_meta only when no marker
  exists.
- sandbox providers: e2b/opensandbox/tenki/boxlite append the
  LocalSandbox-style "Exit Code: N" marker on nonzero exit even with
  non-empty output; aio propagates the SDK's structured exit_code on
  both exec paths the same way; local timeouts append Exit Code: 124;
  and _truncate_bash_output always preserves a trailing exit marker
  (signed included) inside its budget, with a 32-char floor raising any
  smaller configured limit, so the actual shell outcome always survives
  in the output text.
- task_tool: run the checklist offloaded (asyncio.to_thread) on the
  completed branch, failure-isolated; stamp the verdict into result
  metadata and render the per-criterion section into the model-visible
  result text.
- status contract: additive subagent_acceptance_verdict transport with
  read-side structural validation.
- delegation ledger: entry carries the verdict and renders a compact
  acceptance segment; gateway strips caller-forged verdicts from both
  ledger entries and message metadata, like the citation verdict.
- blocking-IO anchor pins the offload (teeth proven red->green); leaf
  read errors catch only OSError/SandboxError so unexpected errors reach
  the task-tool-level isolation instead of being mislabeled.

* fix(harness): close acceptance evidence gaps from review (RFC #4651 PR4)

- negating options: overlap with a matched criterion target is now
  checked by path/nodeid prefix, not exact token equality — excluding a
  sub-path of the criterion's selection (pytest tests --deselect
  tests/unit/test_auth.py) degrades to UNVERIFIED instead of holds
- output attribution: any redirection token in the matched final segment
  makes the recorded tail non-attributable (> / >> / 2> are word
  characters to the parser, so redirection was invisible to the matcher)
- silent-source allowlist narrowed from any *activate suffix to the
  */bin/activate shape
- status_contract docstring: restore the shared-fixture sentence and
  note subagent_acceptance_verdict is deliberately outside the fixture
- executor: update_bash_executions publishes [] (stream carried no
  bash-family calls) instead of collapsing it into None, mirroring
  update_tool_receipts

* fix(harness): close acceptance residual gaps from re-review (RFC #4651 PR4)

- tests_passed: add error outcomes to the fail shapes — "4 passed, 1 error"
  and pytest's "ERROR <nodeid>" short summary no longer satisfy the pass
  shape when the exit status is swallowed (|| true) or absent; zero-error
  counts stay clean.
- file leaves: bound the deliverable read — a "wc -c" shell size probe
  answers files above 50k bytes without loading ~2x their size, honoring
  the host-bash kill switch and falling back to the full read on any
  non-integer rendering, so verdicts never get less sound.
- executor: record the exit marker text as status_marker on harvested bash
  evidence; the leaf detail now reports the marker actually seen instead of
  asserting a failure indistinguishable from the command's own trailing text.
- extend the blocking-IO anchor to drive the probe branch inside the
  offload; teeth re-verified red->green.

* fix(harness): close acceptance forgery and bound gaps from P2 re-review (RFC #4651 PR4)

- file leaves: never read unbounded — size is established first (os.stat on
  the validated local host path, so the host-bash-disabled configuration
  needs no shell; a guarded wc -c on remote providers that renders
  missing/unreadable in its own words). Above the 50k cap the leaf answers
  from the size alone, at/below it the full read runs, and an
  unestablishable size degrades to UNVERIFIED instead of an unlimited
  fallback read.
- output attribution: source/. prefixes are never provably silent — a
  crafted */bin/activate path shape says nothing about what the script
  prints, so sourced segments can no longer lend a passing summary.
- executable identity: an explicitly path-spelled criterion now requires
  the same normalized executable path; the basename rule stays only for
  deliberately bare criterion commands.

* fix(harness): run acceptance size probe outside subagent-controlled state (RFC #4651 PR4)

- remote probe no longer runs in the sandbox's persistent shell: a fresh
  env -i /bin/sh with absolute-path stat/realpath (poisoned functions,
  aliases, PATH, exported functions, IFS, locale cannot steer it), plus a
  marker env routing AIO onto a fresh per-call bash.exec session.
- metadata-only: stat never opens content, so a FIFO deliverable cannot
  block the parent for the provider's idle timeout; non-regular files
  (fifo/dir/symlink) degrade to UNVERIFIED.
- containment canonicalized against the literal mount root: a
  final-component symlink or a swapped parent directory (root included)
  cannot redirect the check outside shared storage; unprovable layouts
  degrade to UNVERIFIED.

* fix(harness): canonicalize probe containment against the canonical mount root (RFC #4651 PR4)

Literal-root equality made every remote file leaf permanently UNVERIFIED
on e2b and Tenki, which realize /mnt/user-data as a symlink to the home
dir by default (e2b bootstrap 'sudo ln -sfn', Tenki best-effort symlink).
Containment now compares the file's realpath against the mount root's
realpath — exactly what the provider's own read path resolves, so probe
and read-back stay consistent; final-component symlinks stay rejected by
the non-dereferencing stat, and an intermediate dir-link escape under a
sane root still lands ESCAPED. The inner script is a module constant and
the suite now executes the composed probe for real against on-disk
layouts (real dir, symlinked prefix, final symlink, fifo, missing,
dir-link escape), which the canned-output stub could not see.

* fix(harness): close bare-criterion negation and CDPATH summary channels (RFC #4651 PR4)

- matching: a criterion with no positional selection target (bare pytest,
  make test) stands for the runner's default selection, so ANY negating
  option (--ignore/--deselect/...) makes the recorded run a different
  selection — unprovable. The overlap guard only sees consumed criterion
  tokens, which a bare criterion does not have; scoped criteria keep the
  unrelated-exclusion behavior.
- attribution: cd is no longer blanket-silent — CDPATH makes cd print the
  resolved (subagent-chosen) destination and the pass shapes match as
  substrings, so one mkdir 'all tests passed' plus an export minted a pass
  for any quiet command. A cd argument or CDPATH= value (export or leading
  assignment) carrying any summary shape makes the segment non-silent;
  shape-free cd dir wrappers keep matching.
- docs: _truncate_bash_output states the effective 32-char floor (the
  guarantee previously read as an unconditional max_chars bound).

* fix(harness): close env-assignment and expansion channels in acceptance matching (RFC #4651 PR4)

Self-audit in the shape of the last review rounds — channels the matcher
classified as accounted-for that can change what runs, narrow the
selection, or lend the summary text:

- env assignments are no longer blanket-stripped: only an allowlist of
  inert display/CI knobs (CI, NO_COLOR, PY_COLORS, ...) may prefix a
  matched span, and a non-allowlisted assignment in any preceding segment
  (pure-assignment or export NAME=) is state pollution — PATH redirects
  the executable, LD_PRELOAD/PYTHONPATH/NODE_OPTIONS inject code,
  PYTEST_ADDOPTS/GOFLAGS/MAKEFILES inject selection-changing inputs,
  BASH_ENV runs arbitrary shell startup. All degrade to unprovable.
- runtime expansions: any span token carrying /$( )/backticks, any
  negating-option value carrying an expansion or glob (unknown excluded
  set), and any extra executed token carrying glob metacharacters
  (crafted option-looking filenames narrow invisibly) are unprovable.
  Criterion-side globs stay self-consistent (literal match).
- cd: an argument carrying a runtime expansion or glob is non-silent
  (unknown destination, unknown print); CDPATH= assignments are now
  handled as state pollution at the match layer, subsuming the
  value-shape special case.

* fix(harness): persistent-shell evidence, exact env sets, option-arity scoping (RFC #4651 PR4)

- tests_passed: on a persistent-shell provider (new
  Sandbox.persistent_shell_sessions capability, set by AioSandbox) every
  leaf degrades to UNVERIFIED — any earlier call in the shared session
  could have mutated the state the clean-looking run executed in, and
  only a fresh controlled session (RFC section 6 verifier) can prove
  otherwise. The flag is read from the provider registry without
  acquiring a sandbox.
- env assignments: the allowlist is gone — no variable is provably inert
  across repositories (CI/DEBUG are routinely read by tests). The span's
  assignment prefix must equal the criterion's exactly (values included,
  order-insensitive); any assignment or export NAME= in a preceding
  segment is state pollution.
- scoping: positional targets are now read by option arity, so a path
  embedded in an option (--basetemp=/tmp/p, --junitxml=/tmp/r.xml) never
  counts as a selection target and an extra positional after such a
  criterion narrows the default selection it denotes.

* fix(harness): stamp shell provenance at harvest, close export/unset and arity gaps (RFC #4651 PR4)

* fix(harness): split physical newlines as shell separators in acceptance matching (RFC #4651 PR4)

* fix(harness): scope cd wrappers to thread data roots, pin accepted boundaries (RFC #4651 PR4)

* fix(harness): preserve criterion connectors, prove file_written readable, fail-closed shell capability (RFC #4651 PR4)

* fix(harness): compare only the connector prefix, tolerate trailing criterion semicolons (RFC #4651 PR4)

* fix(harness): preserve continuation-line operators, keep ./-spelled executable identity (RFC #4651 PR4)

* fix(harness): render criteria single-line so a multiline criterion cannot inject a forged checklist line (RFC #4651 PR4)

* fix(harness): reject parent-traversal executable tokens in acceptance matching (RFC #4651 PR4)

* fix(harness): reject parent-traversal negated values in acceptance matching (RFC #4651 PR4)
2026-09-01 16:13:41 +08:00

424 lines
18 KiB
Python

"""DeerFlow :class:`Sandbox` adapter for an OpenSandbox sync client."""
from __future__ import annotations
import errno
import logging
import posixpath
import re
import shlex
import threading
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
if TYPE_CHECKING:
from collections.abc import Callable
from opensandbox.sync import SandboxSync
logger = logging.getLogger(__name__)
_TERMINAL_ERROR_NAMES = frozenset({"SandboxUnhealthyException"})
_COMMAND_TTL_GRACE = timedelta(seconds=30)
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
def _exception_chain(error: BaseException):
"""Yield an exception and its explicit causes without looping forever."""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
seen.add(id(current))
yield current
current = current.__cause__
def _is_terminal_failure(error: BaseException, *, api_not_found_is_terminal: bool = False) -> bool:
"""Return whether an SDK failure means this remote sandbox is unusable."""
for item in _exception_chain(error):
if isinstance(item, (BrokenPipeError, ConnectionError, EOFError)):
return True
if type(item).__name__ in _TERMINAL_ERROR_NAMES:
return True
status_code = getattr(item, "status_code", None)
if status_code == 410 or (api_not_found_is_terminal and status_code == 404):
return True
return False
def _is_not_found(error: BaseException) -> bool:
for item in _exception_chain(error):
if isinstance(item, FileNotFoundError) or getattr(item, "status_code", None) == 404:
return True
return False
def _join_event_text(chunks) -> str:
"""Reconstruct the line-oriented text emitted by OpenSandbox SSE events."""
return "\n".join(str(chunk).rstrip("\n") for chunk in chunks)
def _append_output(output: str, value: str) -> str:
if not value:
return output
if not output or output.endswith("\n"):
return output + value
return f"{output}\n{value}"
def execution_stdout(execution: Any) -> str:
return _join_event_text(message.text for message in getattr(getattr(execution, "logs", None), "stdout", []))
def format_execution(execution: Any) -> str:
"""Combine stdout, result text, and stderr using DeerFlow's string contract."""
output = execution_stdout(execution)
result = _join_event_text(item.text for item in getattr(execution, "result", []) if getattr(item, "text", None) is not None)
output = _append_output(output, result)
stderr = _join_event_text(message.text for message in getattr(getattr(execution, "logs", None), "stderr", []))
output = _append_output(output, stderr)
error = getattr(execution, "error", None)
if error is not None:
detail = f"{getattr(error, 'name', type(error).__name__)}: {getattr(error, 'value', error)}"
output = _append_output(output, detail)
return output
class OpenSandboxSandbox(Sandbox):
"""Wrap one live ``opensandbox.sync.SandboxSync`` instance."""
#: Every call is a fresh ``run_command`` execution — no shell state
#: survives into the next command.
persistent_shell_sessions = False
def __init__(
self,
id: str,
sandbox: SandboxSync,
*,
run_command_opts_cls: Callable[..., Any],
default_env: dict[str, str] | None = None,
sandbox_timeout: timedelta | None = None,
default_command_timeout: float = 600,
on_terminal_failure: Callable[[str, str], None] | None = None,
) -> None:
super().__init__(id)
if sandbox_timeout is not None and sandbox_timeout.total_seconds() <= 0:
raise ValueError("sandbox_timeout must be positive or None")
if default_command_timeout <= 0:
raise ValueError("default_command_timeout must be positive")
self._sandbox = sandbox
self._run_command_opts_cls = run_command_opts_cls
self._default_env = dict(default_env or {})
self._sandbox_timeout = sandbox_timeout
self._default_command_timeout = float(default_command_timeout)
self._on_terminal_failure = on_terminal_failure
self._state_lock = threading.Lock()
# renew() sets an absolute expiration instead of taking a maximum. Keep
# each renewal and its operation under one lock so a later short file
# operation cannot shorten the horizon of a long-running command.
self._operation_lock = threading.Lock()
self._append_lock = threading.Lock()
self._closed = False
@property
def remote_id(self) -> str:
return str(self._sandbox.id)
@property
def is_closed(self) -> bool:
with self._state_lock:
return self._closed
def destroy(self) -> None:
"""Terminate the remote sandbox and close its SDK resources."""
with self._operation_lock:
with self._state_lock:
if self._closed:
return
error: Exception | None = None
try:
self._sandbox.destroy()
except Exception as exc: # SDK errors are normalized below.
error = exc
finally:
# SandboxSync.destroy() closes its transport even when kill fails,
# so this client cannot safely be reused after either outcome.
with self._state_lock:
self._closed = True
if error is not None and not _is_terminal_failure(error, api_not_found_is_terminal=True):
raise error
def _note_failure(self, error: Exception, *, api_not_found_is_terminal: bool = False) -> None:
if self._on_terminal_failure is None or not _is_terminal_failure(error, api_not_found_is_terminal=api_not_found_is_terminal):
return
try:
self._on_terminal_failure(self.id, str(error))
except Exception:
logger.exception("Terminal OpenSandbox failure callback errored for %s", self.id)
def renew(self) -> None:
"""Refresh this provider-owned remote's server-side lifetime."""
if self._sandbox_timeout is None:
return
with self._operation_lock:
with self._state_lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
try:
self._sandbox.renew(self._sandbox_timeout)
return
except Exception as exc:
failure = exc
self._note_failure(failure, api_not_found_is_terminal=True)
raise failure
def _run(self, command: str, *, env: dict[str, str] | None = None, timeout: float | None = None) -> Any:
command_timeout = self._default_command_timeout if timeout is None else float(timeout)
if command_timeout <= 0:
raise ValueError(f"timeout must be positive, got {timeout}")
sdk_timeout = timedelta(seconds=command_timeout)
renewal_timeout = self._sandbox_timeout
if renewal_timeout is not None:
renewal_timeout = max(renewal_timeout, sdk_timeout + _COMMAND_TTL_GRACE)
opts = self._run_command_opts_cls(timeout=sdk_timeout, envs=env)
with self._operation_lock:
with self._state_lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
try:
if renewal_timeout is not None:
self._sandbox.renew(renewal_timeout)
return self._sandbox.commands.run(command, opts=opts)
except Exception as exc:
failure = exc
# A command-path 404 means the execd endpoint/sandbox is gone. File APIs
# use 404 for an ordinary missing path, so only command operations opt in.
self._note_failure(failure, api_not_found_is_terminal=True)
raise failure
def _file_op(self, operation):
with self._operation_lock:
with self._state_lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
try:
if self._sandbox_timeout is not None:
self._sandbox.renew(self._sandbox_timeout)
except Exception as exc:
failure = exc
renewal_failed = True
else:
try:
return operation(self._sandbox.files)
except Exception as exc:
failure = exc
renewal_failed = False
self._note_failure(failure, api_not_found_is_terminal=renewal_failed)
raise failure
@staticmethod
def _resolve_path(path: str) -> str:
if not isinstance(path, str) or not path:
raise ValueError("path must be a non-empty string")
normalized = path.replace("\\", "/")
if not normalized.startswith("/"):
raise ValueError(f"path must be absolute: '{path}'")
if any(segment == ".." for segment in normalized.split("/")):
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
return normalized
@classmethod
def _resolve_download_path(cls, path: str) -> str:
normalized = cls._resolve_path(path)
stripped = normalized.lstrip("/")
allowed = VIRTUAL_PATH_PREFIX.lstrip("/")
if stripped != allowed and not stripped.startswith(f"{allowed}/"):
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
return normalized
def execute_command(self, command: str, env: dict[str, str] | None = None, timeout: float | None = None) -> str:
_validate_extra_env(env)
merged_env = {**self._default_env, **(env or {})} or None
try:
execution = self._run(command, env=merged_env, timeout=timeout)
except Exception as exc:
logger.error("Failed to execute command in OpenSandbox %s: %s", self.id, exc)
return f"Error: {exc}"
output = format_execution(execution)
exit_code = getattr(execution, "exit_code", None)
if exit_code is None:
detail = output or "no completion or error event"
return f"Error: OpenSandbox command completed without an exit code: {detail}"
if exit_code != 0:
# Mirror LocalSandbox: preserve a nonzero exit in the output text
# even when the command produced output (see e2b_sandbox).
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
def read_file(self, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
resolved = self._resolve_path(path)
try:
content = self._file_op(lambda files: files.read_file(resolved))
except Exception as exc:
logger.error("Failed to read OpenSandbox file %s: %s", resolved, exc)
return f"Error: {exc}"
if start_line is None and end_line is None:
return content or ""
lines = (content or "").splitlines()
start = start_line or 1
end = end_line if end_line is not None else len(lines)
return "\n".join(lines[start - 1 : end])
def write_file(self, path: str, content: str, append: bool = False) -> None:
resolved = self._resolve_path(path)
if not append:
self._file_op(lambda files: files.write_file(resolved, content, mode=644))
return
with self._append_lock:
try:
previous = self._file_op(lambda files: files.read_bytes(resolved))
except Exception as exc:
if not _is_not_found(exc):
raise
previous = b""
data = previous + content.encode("utf-8")
self._file_op(lambda files: files.write_file(resolved, data, mode=644))
def update_file(self, path: str, content: bytes) -> None:
resolved = self._resolve_path(path)
self._file_op(lambda files: files.write_file(resolved, content, mode=644))
def download_file(self, path: str) -> bytes:
resolved = self._resolve_download_path(path)
def read_bounded(files) -> bytes:
chunks: list[bytes] = []
total = 0
stream = files.read_bytes_stream(resolved)
try:
for chunk in stream:
total += len(chunk)
if total > _MAX_DOWNLOAD_SIZE:
raise OSError(errno.EFBIG, f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes", path)
chunks.append(chunk)
finally:
close = getattr(stream, "close", None)
if callable(close):
close()
return b"".join(chunks)
try:
return self._file_op(read_bounded)
except OSError:
raise
except Exception as exc:
raise OSError(f"cannot read '{path}' from OpenSandbox: {exc}") from exc
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
depth = int(max_depth)
if depth < 0:
raise ValueError("max_depth must be non-negative")
resolved = self._resolve_path(path)
execution = self._run(f"find {shlex.quote(resolved)} -maxdepth {depth} \\( -type f -o -type d \\) 2>/dev/null | head -500")
return [line.strip() for line in execution_stdout(execution).splitlines() if line.strip()]
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
if max_results <= 0:
raise ValueError("max_results must be positive")
resolved = self._resolve_path(path)
types = ("f", "d") if include_dirs else ("f",)
type_expr = " -o ".join(f"-type {entry_type}" for entry_type in types)
hard_limit = max(max_results * 4, max_results + 50)
execution = self._run(f"find {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null | head -{hard_limit}")
matches: list[str] = []
root = resolved.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
for entry in execution_stdout(execution).splitlines():
entry = entry.strip()
if not entry or (entry != root and not entry.startswith(root_prefix)) or should_ignore_path(entry):
continue
relative = entry[len(root) :].lstrip("/")
if relative and path_matches(pattern, relative):
matches.append(entry)
if len(matches) >= max_results:
return matches, True
return matches, False
def grep(
self,
path: str,
pattern: str,
*,
glob: str | None = None,
literal: bool = False,
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
if max_results <= 0:
raise ValueError("max_results must be positive")
if not literal:
re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
resolved = self._resolve_path(path)
flags = ["-r", "-H", "-n", "-I"]
if not case_sensitive:
flags.append("-i")
flags.append("-F" if literal else "-E")
portable_flags = list(flags)
if glob is not None:
include_pattern = glob.split("/")[-1] or glob
flags.append(shlex.quote(f"--include={include_pattern}"))
per_file_cap = max(max_results, 50)
flags.append(f"-m{per_file_cap}")
hard_limit = max(max_results * 4, max_results + 50)
arguments = f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null"
primary = "grep " + " ".join(flags) + arguments
fallback = "grep " + " ".join(portable_flags) + arguments
command = f'{{ {primary}; status=$?; [ "$status" -eq 2 ] && {fallback}; }} | head -{hard_limit}'
execution = self._run(command)
root = resolved.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
matches: list[GrepMatch] = []
seen_positions: set[tuple[str, int]] = set()
for raw in execution_stdout(execution).splitlines():
try:
file_path, line_number_text, line = raw.split(":", 2)
line_number = int(line_number_text)
except ValueError:
continue
if should_ignore_path(file_path):
continue
if glob is not None:
if file_path != root and not file_path.startswith(root_prefix):
continue
relative = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/")
if not path_matches(glob, relative):
continue
position = (file_path, line_number)
if position in seen_positions:
continue
seen_positions.add(position)
matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line)))
if len(matches) >= max_results:
return matches, True
return matches, False
def ping(self, timeout: float = 10) -> bool:
if self.is_closed:
return False
try:
execution = self._run("true", timeout=timeout)
except Exception as exc:
logger.warning("OpenSandbox %s health check failed: %s", self.id, exc)
return False
return getattr(execution, "exit_code", None) == 0
__all__ = ["OpenSandboxSandbox"]