simbiot-harness/venv/lib/python3.12/site-packages/strands/sandbox/posix_shell.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

241 lines
9.8 KiB
Python

"""Shell sandbox with default implementations for file and code operations.
Subclasses only need to implement :meth:`PosixShellSandbox.execute_streaming` —
all other operations are implemented by running shell commands through it. Use
this for remote environments where only shell access is available (Docker
containers, SSH connections, cloud runtimes).
Mirrors ``strands-ts/src/sandbox/posix-shell.ts``.
"""
import base64
import logging
import shlex
import uuid
from abc import ABC
from collections.abc import AsyncGenerator
from typing import Any
from .base import Sandbox
from .constants import ENV_KEY_PATTERN, LANGUAGE_PATTERN
from .errors import SandboxPathNotFoundError
from .types import ExecutionResult, FileInfo, StreamChunk
logger = logging.getLogger(__name__)
def validate_env_keys(env: dict[str, str]) -> None:
"""Validate environment variable names against :data:`ENV_KEY_PATTERN`.
Args:
env: Mapping of environment variable names to values.
Raises:
ValueError: If any key is not a valid POSIX environment variable name.
"""
for key in env:
if not ENV_KEY_PATTERN.fullmatch(key):
raise ValueError(f"Invalid environment variable name: {key}")
def build_shell_env_prefix(env: dict[str, str] | None = None) -> str:
"""Build a shell ``export KEY=VALUE && ...`` prefix, or ``""`` when empty.
Keys are validated; values are escaped with :func:`shlex.quote`. Used by
shell-string backends (e.g. SSH); backends that set env via native flags
(e.g. Docker's ``-e``) call :func:`validate_env_keys` directly.
Uses ``export`` rather than an ``env KEY=VALUE`` command wrapper so the
variables are set in the shell itself and inherited by every stage of a
pipeline. ``execute_code`` runs ``base64 ... | <lang>``, and an ``env``
wrapper would only bind the left side of the pipe, never reaching the
interpreter. The trailing ``&&`` keeps the surrounding
``cd ... && <prefix><command>`` chain fail-fast.
Args:
env: Mapping of environment variable names to values.
Returns:
The shell ``export ... && `` prefix, or an empty string when ``env`` is
``None`` or empty.
Raises:
ValueError: If any key is not a valid POSIX environment variable name.
"""
if not env:
return ""
validate_env_keys(env)
assignments = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
return f"export {assignments} && "
def _eof_marker() -> str:
"""Generate a unique heredoc EOF marker, mirroring the TS ``STRANDS_EOF_`` token."""
return f"STRANDS_EOF_{uuid.uuid4().hex[:16]}"
class PosixShellSandbox(Sandbox, ABC):
"""Abstract sandbox that provides shell-based defaults for file and code operations.
Assumes a POSIX-compatible shell (sh/bash) on the target.
Subclasses only need to implement :meth:`execute_streaming`. The remaining
operations — ``execute_code_streaming``, ``read_file``, ``write_file``,
``remove_file``, and ``list_files`` — are implemented via shell commands
piped through :meth:`execute_streaming`.
Subclasses may override any method with a native implementation for better
performance or to handle edge cases (e.g., binary-safe file transfer via
Docker stdin pipes, or native API calls for cloud backends).
Subclasses are responsible for honoring the execution options in
:meth:`execute_streaming`, or they have no effect:
- ``env`` — backends that build a shell-command string prepend
:func:`build_shell_env_prefix`; backends that set env via process flags
(e.g. Docker's ``-e``) call :func:`validate_env_keys` and pass the values
directly. An implementation that ignores ``env`` will silently drop the
caller's variables.
- ``timeout`` — the base class does not enforce a timeout; a subclass that
does not wire ``timeout`` into its process supervision will silently run
without any time limit.
- ``cwd`` — similarly must be applied by the subclass.
"""
async def execute_code_streaming(
self,
code: str,
language: str,
*,
timeout: float | None = None,
cwd: str | None = None,
env: dict[str, str] | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
"""Execute code by piping it to a language interpreter over the shell.
The code is base64-encoded and decoded inside a quoted heredoc on the
target, then piped to the interpreter (``base64 -d << 'EOF' | <lang>``).
This transports arbitrary source — including shell metacharacters,
quotes, and newlines — without injection risk. The ``language`` is
validated against :data:`LANGUAGE_PATTERN` first.
Args:
code: The source code to execute.
language: The interpreter to use (e.g., ``"python3"``, ``"node"``).
timeout: Maximum execution time in seconds. ``None`` means no timeout.
cwd: Working directory for execution.
env: Environment variables to set for this execution.
**kwargs: Additional keyword arguments for forward compatibility.
Yields:
:class:`StreamChunk` objects for output, then a final
:class:`ExecutionResult`.
Raises:
ValueError: If ``language`` contains invalid characters.
"""
if not LANGUAGE_PATTERN.fullmatch(language):
raise ValueError(f"language parameter contains invalid characters: {language}")
encoded = base64.b64encode(code.encode()).decode("ascii")
eof = _eof_marker()
command = f"base64 -d << '{eof}' | {language}\n{encoded}\n{eof}"
async for chunk in self.execute_streaming(command, timeout=timeout, cwd=cwd, env=env, **kwargs):
yield chunk
async def read_file(self, path: str, **kwargs: Any) -> bytes:
"""Read a file as raw bytes via base64 over the shell.
Args:
path: Path to the file to read.
**kwargs: Additional keyword arguments for forward compatibility.
Returns:
The file contents as raw bytes.
Raises:
FileNotFoundError: If the file does not exist or cannot be read.
OSError: If the command succeeds but its output is not valid base64
(e.g. a shell profile or locale warning prepended text to stdout).
"""
result = await self.execute(f"base64 < {shlex.quote(path)}")
if result.exit_code != 0:
raise FileNotFoundError(result.stderr or f"Failed to read file: {path}")
# base64 output is ASCII-safe text; strip whitespace (line wrapping) and decode.
try:
# binascii.Error (raised by b64decode on malformed input) subclasses ValueError.
return base64.b64decode("".join(result.stdout.split()))
except ValueError as e:
raise OSError(f"Failed to decode base64 contents of file: {path}") from e
async def write_file(self, path: str, content: bytes, **kwargs: Any) -> None:
"""Write raw bytes to a file via base64 over the shell.
Parent directories are created via ``mkdir -p``. The base64-encoded
content is decoded inside a quoted heredoc on the target, preserving
arbitrary binary content.
Args:
path: Path to the file to write.
content: The content to write.
**kwargs: Additional keyword arguments for forward compatibility.
Raises:
OSError: If the file cannot be written.
"""
encoded = base64.b64encode(content).decode("ascii")
quoted = shlex.quote(path)
eof = _eof_marker()
cmd = f"mkdir -p \"$(dirname {quoted})\" && base64 -d << '{eof}' > {quoted}\n{encoded}\n{eof}"
result = await self.execute(cmd)
if result.exit_code != 0:
raise OSError(result.stderr or f"Failed to write file: {path}")
async def remove_file(self, path: str, **kwargs: Any) -> None:
"""Remove a file via ``rm`` over the shell.
Args:
path: Path to the file to remove.
**kwargs: Additional keyword arguments for forward compatibility.
Raises:
FileNotFoundError: If the file does not exist.
"""
result = await self.execute(f"rm {shlex.quote(path)}")
if result.exit_code != 0:
raise FileNotFoundError(result.stderr or f"Failed to remove file: {path}")
async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]:
"""List directory contents via ``ls -1ap`` parsing.
Args:
path: Path to the directory to list.
**kwargs: Additional keyword arguments for forward compatibility.
Returns:
A list of :class:`FileInfo` entries (``size`` is always ``None`` for
this shell-based listing).
Raises:
SandboxPathNotFoundError: If the directory does not exist (or ``path``
is not a directory).
OSError: If the listing fails for another reason.
"""
quoted = shlex.quote(path)
# Exit 77 distinguishes a missing directory from ls's own failures (locale-independent).
result = await self.execute(f"test -d {quoted} || exit 77; env QUOTING_STYLE=literal ls -1ap {quoted}")
if result.exit_code == 77:
raise SandboxPathNotFoundError(path)
if result.exit_code != 0:
raise OSError(result.stderr or f"Failed to list directory: {path}")
entries: list[FileInfo] = []
for raw in result.stdout.split("\n"):
line = raw.rstrip("\r")
if not line or line in ("./", "../"):
continue
is_dir = line.endswith("/")
name = line[:-1] if is_dir else line
if name:
entries.append(FileInfo(name=name, is_dir=is_dir))
return entries