diff --git a/package-lock.json b/package-lock.json index 53f1bd8519..a78ae8c572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1938,7 +1938,6 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.14.1" }, @@ -2499,6 +2498,7 @@ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3674,6 +3674,86 @@ "resolved": "strands-ts", "link": true }, + "node_modules/@strands-agents/shell": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@strands-agents/shell/-/shell-0.1.0.tgz", + "integrity": "sha512-TdwY7uW4Dyfw6LUuITOON4qCLK6Pst4vQbJIMfAMwMqZBKFC7eQZeMMChUQ8JHvEa9y3BrDpxj7SPYKU1xOI5w==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@strands-agents/shell-darwin-arm64": "0.1.0", + "@strands-agents/shell-darwin-x64": "0.1.0", + "@strands-agents/shell-linux-arm64-gnu": "0.1.0", + "@strands-agents/shell-linux-x64-gnu": "0.1.0" + } + }, + "node_modules/@strands-agents/shell-darwin-arm64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@strands-agents/shell-darwin-arm64/-/shell-darwin-arm64-0.1.0.tgz", + "integrity": "sha512-Vs1yZa95WpUPSwdFuiZZmChIwaTCQ0Cr4vsCWhM1ddm0dX2vNto6ClOu3oqKdHr/MlEW+R1Qf6uWrAEcAK6v0Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@strands-agents/shell-darwin-x64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@strands-agents/shell-darwin-x64/-/shell-darwin-x64-0.1.0.tgz", + "integrity": "sha512-MymItpqQbiKjJShjCToMA91lPifNJpBeFx2fzYsGwVJBCodygme4lEmmm/HW2ureR+q9gEa3zz0hsRdpTI7qvQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@strands-agents/shell-linux-arm64-gnu": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@strands-agents/shell-linux-arm64-gnu/-/shell-linux-arm64-gnu-0.1.0.tgz", + "integrity": "sha512-AKVNQc3DhNsBR0NG0JPeQwbNojxqfBa4uamplYm6VTKpDyqTcvJ4ivuawkO+AThtQFflq0N8igW/9VsaczFasQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@strands-agents/shell-linux-x64-gnu": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@strands-agents/shell-linux-x64-gnu/-/shell-linux-x64-gnu-0.1.0.tgz", + "integrity": "sha512-pRkN2rC3Jt5vSYzJur4VX4uQSq3uFcMsUoIWAnQWvVUZscbd8RPoqu5sl551cOWNIrcuogp4Fd3svgVOM3wp8g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, "node_modules/@strands-agents/strandly": { "resolved": "strandly", "link": true @@ -3882,6 +3962,7 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -4290,6 +4371,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4322,7 +4404,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4339,7 +4420,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -4774,7 +4854,6 @@ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", - "peer": true, "dependencies": { "object-assign": "^4", "vary": "^1" @@ -5135,6 +5214,7 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5531,7 +5611,6 @@ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", - "peer": true, "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -5563,6 +5642,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5606,7 +5686,6 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", - "peer": true, "dependencies": { "ip-address": "^10.2.0" }, @@ -5899,7 +5978,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -6265,7 +6343,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 12" } @@ -6476,7 +6553,6 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6536,8 +6612,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -7439,6 +7514,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7484,7 +7560,6 @@ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.20.0" } @@ -8448,7 +8523,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -8518,6 +8592,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8598,6 +8673,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -8953,6 +9029,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -8962,7 +9039,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } @@ -8987,6 +9063,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1037.0", + "@strands-agents/shell": "0.1.0", "@types/json-schema": "^7.0.15", "uuid": "^14.0.0", "yaml": "^2.8.3" @@ -9038,6 +9115,9 @@ "engines": { "node": ">=20.0.0" }, + "optionalDependencies": { + "@strands-agents/shell": "^0.1.0" + }, "peerDependencies": { "@a2a-js/sdk": "^0.3.10", "@ai-sdk/provider": "^3.0.0", diff --git a/strands-py/pyproject.toml b/strands-py/pyproject.toml index 393afbdb55..8a8ca7aeeb 100644 --- a/strands-py/pyproject.toml +++ b/strands-py/pyproject.toml @@ -58,6 +58,7 @@ sagemaker = [ "openai>=1.68.0,<3.0.0", # SageMaker uses OpenAI-compatible interface ] otel = ["opentelemetry-exporter-otlp-proto-http>=1.30.0,<2.0.0"] +shell = ["strands-shell>=0.1.0,<0.2.0"] docs = [ "sphinx>=5.0.0,<10.0.0", "sphinx-rtd-theme>=1.0.0,<4.0.0", @@ -86,7 +87,7 @@ bidi-openai = ["websockets>=15.0.0,<17.0.0"] cedar = ["cedarpy==4.8.0", "cedar-policy-mcp-schema-generator==0.6.0"] -all = ["strands-agents[a2a,anthropic,cedar,docs,gemini,litellm,llamaapi,mistral,ollama,openai,writer,sagemaker,otel]"] +all = ["strands-agents[a2a,anthropic,cedar,docs,gemini,litellm,llamaapi,mistral,ollama,openai,shell,writer,sagemaker,otel]"] bidi-all = ["strands-agents[a2a,bidi,bidi-io,bidi-gemini,bidi-openai,docs,otel]"] dev = [ diff --git a/strands-py/src/strands/experimental/__init__.py b/strands-py/src/strands/experimental/__init__.py index cbd9a713eb..c722063f8e 100644 --- a/strands-py/src/strands/experimental/__init__.py +++ b/strands-py/src/strands/experimental/__init__.py @@ -3,7 +3,7 @@ This module implements experimental features that are subject to change in future revisions without notice. """ -from . import checkpoint, steering, tools +from . import checkpoint, sandbox, steering, tools from .agent_config import config_to_agent -__all__ = ["checkpoint", "config_to_agent", "tools", "steering"] +__all__ = ["checkpoint", "config_to_agent", "sandbox", "tools", "steering"] diff --git a/strands-py/src/strands/experimental/sandbox/__init__.py b/strands-py/src/strands/experimental/sandbox/__init__.py new file mode 100644 index 0000000000..abf749e4eb --- /dev/null +++ b/strands-py/src/strands/experimental/sandbox/__init__.py @@ -0,0 +1,19 @@ +"""Experimental sandbox implementations. + +This module ships sandbox backends that depend on optional, separately-installed +packages and whose APIs may change without notice. + +- :class:`StrandsShellSandbox` — a :class:`~strands.sandbox.base.Sandbox` backed + by `Strands Shell `_, an in-process + Bourne-compatible shell with declarative filesystem, network, and credential + mediation. Requires ``pip install strands-agents[shell]``. + +The sandbox vends ``sandbox_bash`` and ``sandbox_file_editor`` tools (built from +the :func:`~strands.vended_tools.make_bash` / :func:`~strands.vended_tools.make_file_editor` +factories) via :meth:`StrandsShellSandbox.get_tools`; an agent constructed with +the sandbox registers them automatically. +""" + +from .strands_shell import StrandsShellSandbox + +__all__ = ["StrandsShellSandbox"] diff --git a/strands-py/src/strands/experimental/sandbox/_worker.py b/strands-py/src/strands/experimental/sandbox/_worker.py new file mode 100644 index 0000000000..451ae312a2 --- /dev/null +++ b/strands-py/src/strands/experimental/sandbox/_worker.py @@ -0,0 +1,88 @@ +"""Single-thread worker that pins a thread-unsendable object to one OS thread. + +The native ``strands_shell.Shell`` is built with PyO3 and is *unsendable*: it +panics the interpreter if created, used, or dropped on any thread other than the +one that created it. Strands executes tools on a pool of threads and from +asyncio, so the shell must be confined to a thread of its own. + +:class:`_ShellWorker` owns a dedicated daemon thread. The shell is constructed on +that thread and kept as a local variable in the thread's run loop — never stored +anywhere reachable from another thread — so when the loop exits, the shell is +dropped on the same thread that created it. Callers submit functions that run on +the worker thread and receive a :class:`concurrent.futures.Future`. +""" + +import concurrent.futures +import queue +import threading +from collections.abc import Callable +from typing import Any + + +class _ShellWorker: + """Run callables against a thread-pinned object on a single dedicated thread. + + Args: + factory: Builds the pinned object on the worker thread. Any exception it + raises is re-raised from the constructor. + + Raises: + BaseException: Whatever ``factory`` raised, propagated to the caller. + """ + + def __init__(self, factory: Callable[[], Any]) -> None: + self._queue: queue.SimpleQueue[tuple[Callable[[Any], Any], concurrent.futures.Future[Any]] | None] = ( + queue.SimpleQueue() + ) + self._ready = threading.Event() + self._init_error: BaseException | None = None + self._thread = threading.Thread(target=self._run, args=(factory,), name="strands-shell", daemon=True) + self._thread.start() + self._ready.wait() + if self._init_error is not None: + raise self._init_error + + def _run(self, factory: Callable[[], Any]) -> None: + """Build the pinned object, then service the queue until shut down. + + The object lives only as a local here, so it is created and dropped on + this thread — satisfying the unsendable contract end to end. + """ + try: + obj = factory() + except BaseException as e: # noqa: BLE001 - surfaced to constructor caller + self._init_error = e + self._ready.set() + return + # Drop the factory immediately: it may close over caller state, and this + # frame stays alive for the worker's whole lifetime (it is a GC root), so + # holding the factory could keep that state — including the owner — alive + # and prevent the finalizer-driven shutdown. + del factory + self._ready.set() + while True: + item = self._queue.get() + if item is None: + break + fn, future = item + if not future.set_running_or_notify_cancel(): + continue + try: + future.set_result(fn(obj)) + except BaseException as e: # noqa: BLE001 - propagated via the future + future.set_exception(e) + + def submit(self, fn: Callable[[Any], Any]) -> "concurrent.futures.Future[Any]": + """Schedule ``fn(obj)`` on the worker thread and return its future.""" + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + self._queue.put((fn, future)) + return future + + def shutdown(self) -> None: + """Signal the worker to stop; the pinned object is dropped on its thread. + + Idempotent and safe to call from any thread (it only enqueues a + sentinel). The thread is a daemon, so a missed shutdown never blocks + interpreter exit. + """ + self._queue.put(None) diff --git a/strands-py/src/strands/experimental/sandbox/strands_shell.py b/strands-py/src/strands/experimental/sandbox/strands_shell.py new file mode 100644 index 0000000000..9700c4048c --- /dev/null +++ b/strands-py/src/strands/experimental/sandbox/strands_shell.py @@ -0,0 +1,419 @@ +"""Sandbox backed by `Strands Shell `_. + +:class:`StrandsShellSandbox` runs commands and file operations inside Strands +Shell — a Bourne-compatible shell that executes entirely in userspace, with no +``fork``/``exec``/syscalls. The agent only reaches what you declare: bound host +paths, allowlisted URLs, and per-URL credentials it never sees. + +This is an **experimental** feature and may change without notice. It requires +the optional ``strands-shell`` dependency:: + + pip install strands-agents[shell] + +Example:: + + from strands import Agent + from strands.experimental.sandbox import StrandsShellSandbox + + sandbox = StrandsShellSandbox( + binds=[{"source": "/my/project", "destination": "/workspace", "mode": "copy"}], + timeout=30.0, + ) + # The sandbox vends ``sandbox_bash`` and ``sandbox_file_editor`` tools, which + # the agent registers automatically. + agent = Agent(sandbox=sandbox) + agent("List the Python files in /workspace and summarize them") + +The native shell is *thread-pinned*: it must be created, used, and dropped on a +single OS thread. This sandbox enforces that by confining the shell to a +dedicated worker thread (:class:`._worker._ShellWorker`) and routing every +operation through it, so it is safe to use from Strands' threaded tool execution +and from asyncio. +""" + +import asyncio +import logging +import shlex +import uuid +import weakref +from collections.abc import AsyncGenerator, Callable, Sequence +from typing import TYPE_CHECKING, Any + +from ...sandbox.base import Sandbox +from ...sandbox.constants import LANGUAGE_PATTERN +from ...sandbox.errors import SandboxPathNotFoundError +from ...sandbox.posix_shell import build_shell_env_prefix +from ...sandbox.types import ExecutionResult, FileInfo, StreamChunk +from ...types.tools import AgentTool +from ...vended_tools.bash import make_bash +from ...vended_tools.bash.types import SANDBOX_BASH_DESCRIPTION +from ...vended_tools.file_editor import make_file_editor +from ...vended_tools.file_editor.file_editor import DEFAULT_FILE_EDITOR_DESCRIPTION +from ._worker import _ShellWorker + +if TYPE_CHECKING: + import strands_shell + +logger = logging.getLogger(__name__) + + +class StrandsShellSandbox(Sandbox): + """A :class:`~strands.sandbox.base.Sandbox` backed by Strands Shell. + + Constructed with the same configuration surface as ``strands_shell.Shell`` + (binds, credentials, allowlisted URLs, env, umask, timeout, resource limits) + plus the ability to load a TOML ``config_file``. The agent cannot change this + configuration — it is fixed by whoever creates the sandbox. + + File operations use the shell's native VFS API (reporting real ``size`` + metadata); command execution runs through the in-process shell. Code + execution writes the source to a temporary VFS file and runs the requested + interpreter against it (Strands Shell ships ``lua``; other interpreters are + only available if present in the sandbox). + + :meth:`get_tools` vends ``sandbox_file_editor`` and ``sandbox_bash`` tools + bound to this sandbox, with descriptions that surface its mounts, timeout, and + allowlists to the model. An agent constructed with this sandbox registers them + automatically (``Agent(sandbox=sandbox)``). + + .. note:: + Unlike the base :class:`~strands.sandbox.base.Sandbox` contract, the + per-call ``timeout`` argument to :meth:`execute`/:meth:`execute_streaming` + (and the code variants) is **ignored**. Strands Shell enforces a single + wall-clock timeout configured at construction (the ``timeout`` keyword); + set it there to bound command duration. + """ + + def __init__( + self, + *, + binds: Sequence[dict[str, Any]] | None = None, + credentials: Sequence[dict[str, str]] | None = None, + allowed_urls: Sequence[str] | None = None, + env: dict[str, str] | None = None, + umask: int | None = None, + timeout: float | None = None, + limits: "strands_shell.Limits | None" = None, + config_file: str | None = None, + ) -> None: + """Initialize the sandbox and build the underlying shell. + + Args: + binds: Bind mounts exposing host paths in the sandbox. Each is a dict + with ``source`` and ``destination`` (both required), an optional + ``mode`` (``"direct"`` passthrough, the default, or ``"copy"`` for + a build-time snapshot), and an optional ``readonly`` flag. + credentials: Per-URL credential injection rules. Each is a dict with a + ``url`` and exactly one of ``token`` or ``env_var``. The agent + never sees the secret; the kernel injects it per request. + allowed_urls: URL prefixes ``curl`` may reach, bypassing the default + SSRF guard for those hosts. Listing ``"https://"`` disables SSRF + protection entirely — list specific endpoints instead. + env: Environment variables to set in the shell. + umask: File-creation mask (e.g. ``0o022``). + timeout: Per-command wall-clock timeout in seconds. Must be positive + and finite. ``None`` means no timeout. + limits: A ``strands_shell.Limits`` bundle of resource caps (output + size, file size, fds, inodes, ...). ``None`` uses the shell's + defaults. + config_file: Path to a TOML config file. Merged first; explicit + keyword arguments above take precedence over it. + + Raises: + ImportError: If the optional ``strands-shell`` package is not installed. + ValueError: If ``timeout`` is not a positive, finite number. + """ + try: + import strands_shell + except ImportError as e: + raise ImportError( + "StrandsShellSandbox requires the 'strands-shell' package. " + "Install it with: pip install strands-agents[shell]" + ) from e + + self._binds = [dict(b) for b in (binds or [])] + self._allowed_urls = list(allowed_urls or []) + self._credentials = [dict(c) for c in (credentials or [])] + self._timeout = timeout + + bind_objs = [ + strands_shell.Bind( + source=b["source"], + destination=b["destination"], + mode=b.get("mode", "direct"), + readonly=b.get("readonly", False), + ) + for b in self._binds + ] + cred_objs = [ + strands_shell.Cred(url=c["url"], token=c.get("token"), env_var=c.get("env_var")) for c in self._credentials + ] + + # The native shell is thread-pinned (unsendable): it must be created, + # used, and dropped on one OS thread. The worker owns that thread and + # holds the shell as a thread-local, never exposing it to other threads. + # + # `_build` must capture only locals, never `self`: the worker thread + # keeps the factory alive for its whole lifetime, so a captured `self` + # would root the sandbox and stop it from ever being garbage-collected — + # which would mean the finalizer below never runs and the thread leaks. + allowed_urls = self._allowed_urls + + def _build() -> "strands_shell.Shell": + return strands_shell.Shell( + binds=bind_objs, + credentials=cred_objs, + allowed_urls=allowed_urls, + env=env, + umask=umask, + timeout=timeout, + limits=limits, + config_file=config_file, + ) + + self._worker = _ShellWorker(_build) + # Drop the worker (and the shell on its own thread) when this sandbox is + # garbage collected, even without an explicit close. + self._finalizer = weakref.finalize(self, self._worker.shutdown) + + # ---- Thread-pinned dispatch ---- + + async def _call(self, fn: Callable[["strands_shell.Shell"], Any]) -> Any: + """Run ``fn(shell)`` on the pinned worker thread and await the result.""" + return await asyncio.wrap_future(self._worker.submit(fn)) + + # ---- Command execution ---- + + async def execute_streaming( + self, + command: str, + *, + timeout: float | None = None, + cwd: str | None = None, + env: dict[str, str] | None = None, + **kwargs: Any, + ) -> AsyncGenerator[StreamChunk | ExecutionResult, None]: + """Execute a shell command in the sandbox. + + Strands Shell returns complete output rather than streaming it, so this + yields the stdout and stderr as two :class:`StreamChunk` objects followed + by the final :class:`ExecutionResult`. + + ``cwd`` and ``env`` are applied for this command only, by wrapping it in a + subshell, leaving the session's persistent state untouched. The ``timeout`` + argument is **not** honored per call — Strands Shell enforces the timeout + configured at construction; pass ``timeout`` to the constructor instead. + + Args: + command: The shell command to execute. + timeout: Ignored (see above); the constructor's ``timeout`` governs. + cwd: Working directory for this command. ``None`` uses the session's + current directory. + env: Environment variables for this command only. + **kwargs: Additional keyword arguments for forward compatibility. + + Yields: + Two :class:`StreamChunk` objects (stdout, stderr) then an + :class:`ExecutionResult`. + + Raises: + ValueError: If an environment variable name is invalid. + """ + wrapped = self._wrap_command(command, cwd=cwd, env=env) + output = await self._call(lambda shell: shell.run(wrapped)) + if output.stdout: + yield StreamChunk(data=output.stdout, stream_type="stdout") + if output.stderr: + yield StreamChunk(data=output.stderr, stream_type="stderr") + yield ExecutionResult(exit_code=output.status, stdout=output.stdout, stderr=output.stderr) + + 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 source code by writing it to a temporary VFS file and running an interpreter. + + Strands Shell ships ``lua`` (Lua 5.4); other interpreters (``python3``, + ``node``, ...) only run if present in the sandbox. ``language`` is + validated against :data:`~strands.sandbox.constants.LANGUAGE_PATTERN`. + + Args: + code: The source code to execute. + language: The interpreter to use (e.g. ``"lua"``). + timeout: Ignored per call; the constructor's ``timeout`` governs. + cwd: Working directory for this command. + env: Environment variables for this command only. + **kwargs: Additional keyword arguments for forward compatibility. + + Yields: + :class:`StreamChunk` objects then a final :class:`ExecutionResult`. + + Raises: + ValueError: If ``language`` contains invalid characters or an + environment variable name is invalid. + """ + if not LANGUAGE_PATTERN.fullmatch(language): + raise ValueError(f"language parameter contains invalid characters: {language}") + + # Write the source to a unique temp file in the VFS, then run the + # interpreter against it. This avoids shell-escaping the code and works + # without a `base64` command (which Strands Shell does not provide). + path = f"/tmp/strands_code_{_token()}" + try: + await self.write_file(path, code.encode("utf-8")) + except OSError as e: + # A VFS write failure (e.g. inode/size cap) is reported as a failed + # execution rather than raised, matching how shell-backed sandboxes + # surface failures through the stream. + yield ExecutionResult(exit_code=1, stdout="", stderr=f"failed to stage code for execution: {e}") + return + try: + command = f"{language} {path}" + wrapped = self._wrap_command(command, cwd=cwd, env=env) + output = await self._call(lambda shell: shell.run(wrapped)) + finally: + try: + await self.remove_file(path) + except OSError: + logger.debug("path=<%s> | failed to remove temporary code file", path) + + if output.stdout: + yield StreamChunk(data=output.stdout, stream_type="stdout") + if output.stderr: + yield StreamChunk(data=output.stderr, stream_type="stderr") + yield ExecutionResult(exit_code=output.status, stdout=output.stdout, stderr=output.stderr) + + @staticmethod + def _wrap_command(command: str, *, cwd: str | None, env: dict[str, str] | None) -> str: + """Wrap ``command`` in a subshell applying ``cwd``/``env`` without leaking state. + + Raises: + ValueError: If an environment variable name is invalid. + """ + if cwd is None and not env: + return command + env_prefix = build_shell_env_prefix(env) + cd_prefix = f"cd {shlex.quote(cwd)} && " if cwd is not None else "" + return f"( {cd_prefix}{env_prefix}{command} )" + + # ---- VFS file operations (native) ---- + + async def read_file(self, path: str, **kwargs: Any) -> bytes: + """Read a file from the sandbox VFS as raw bytes. + + 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. + """ + data: bytes = await self._call(lambda shell: bytes(shell.read_file(path))) + return data + + async def write_file(self, path: str, content: bytes, **kwargs: Any) -> None: + """Write raw bytes to a file in the sandbox VFS, creating parent directories. + + 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. + """ + await self._call(lambda shell: shell.write_file(path, content)) + + async def remove_file(self, path: str, **kwargs: Any) -> None: + """Remove a file from the sandbox VFS. + + Args: + path: Path to the file to remove. + **kwargs: Additional keyword arguments for forward compatibility. + + Raises: + FileNotFoundError: If the file does not exist. + """ + await self._call(lambda shell: shell.remove_file(path)) + + async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]: + """List files in a sandbox VFS directory. + + Args: + path: Path to the directory to list. + **kwargs: Additional keyword arguments for forward compatibility. + + Returns: + A list of :class:`~strands.sandbox.types.FileInfo` entries. + + Raises: + SandboxPathNotFoundError: If the directory does not exist. + """ + try: + entries = await self._call(lambda shell: shell.list_files(path)) + except FileNotFoundError as e: + # Map the shell's missing-path error onto the sandbox contract so the + # file editor and other callers can distinguish absence from failure. + raise SandboxPathNotFoundError(path) from e + return [FileInfo(name=e.name, is_dir=e.is_dir, size=e.size) for e in entries] + + # ---- Tools ---- + + def get_tools(self) -> list[AgentTool]: + """Return ``sandbox_file_editor`` and ``sandbox_bash`` tools bound to this sandbox. + + These are registered automatically when an agent is constructed with this + sandbox (``Agent(sandbox=sandbox)``); a tool is skipped if the user + already registered one with the same name. Each tool's description + surfaces the sandbox's mounts, timeout, and allowlists so the model knows + what it can reach. + + Returns: + The tools bound to this sandbox. + """ + suffix = self._dynamic_suffix() + return [ + make_file_editor( + sandbox=self, + name="sandbox_file_editor", + description=DEFAULT_FILE_EDITOR_DESCRIPTION + suffix, + ), + make_bash( + sandbox=self, + name="sandbox_bash", + description=SANDBOX_BASH_DESCRIPTION + suffix, + ), + ] + + def _dynamic_suffix(self) -> str: + """Build a description suffix listing this sandbox's reachable surface, or ``""``.""" + info: list[str] = [] + bind_dests = [b["destination"] for b in self._binds if b.get("destination")] + if bind_dests: + info.append(f"Host paths are mounted at: {', '.join(bind_dests)}.") + info.append("Writes outside mounted paths are in-memory only and do not reach the host.") + if self._timeout is not None: + info.append(f"Commands time out after {self._timeout}s.") + if self._allowed_urls: + info.append(f"curl may reach these URL prefixes: {', '.join(self._allowed_urls)}.") + cred_urls = [c["url"] for c in self._credentials if c.get("url")] + if cred_urls: + info.append( + f"Credentials are injected automatically for: {', '.join(cred_urls)} " + "(do not add auth headers or tokens yourself)." + ) + return (" " + " ".join(info)) if info else "" + + +def _token() -> str: + """Generate a short unique token for temporary file names.""" + return uuid.uuid4().hex[:16] diff --git a/strands-py/tests/strands/experimental/sandbox/__init__.py b/strands-py/tests/strands/experimental/sandbox/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-py/tests/strands/experimental/sandbox/test_strands_shell.py b/strands-py/tests/strands/experimental/sandbox/test_strands_shell.py new file mode 100644 index 0000000000..b0444ad736 --- /dev/null +++ b/strands-py/tests/strands/experimental/sandbox/test_strands_shell.py @@ -0,0 +1,323 @@ +"""Tests for :class:`StrandsShellSandbox` against the real ``strands-shell``. + +These run the in-process shell directly (no mocking), so they are skipped when +the optional ``strands-shell`` package is not installed. They cover command +execution, the thread-pinned lifecycle, native file operations, code execution, +cwd/env scoping, the vended tools, and the dynamic tool descriptions. +""" + +import asyncio +import gc +import importlib.util +import threading +import time + +import pytest + +from strands import Agent +from strands.sandbox.errors import SandboxPathNotFoundError +from strands.sandbox.types import ExecutionResult, StreamChunk +from tests.fixtures.mocked_model_provider import MockedModelProvider + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("strands_shell") is None, + reason="optional 'strands-shell' package not installed", +) + +# Imported lazily-but-at-module-top: guarded by the skip above so collection +# never fails when the optional dep is absent. +if importlib.util.find_spec("strands_shell") is not None: + from strands.experimental.sandbox import StrandsShellSandbox + + +@pytest.fixture +def sandbox(): + return StrandsShellSandbox(timeout=15.0) + + +@pytest.fixture +def workspace_sandbox(tmp_path): + (tmp_path / "hello.txt").write_text("hello from host") + return StrandsShellSandbox( + binds=[{"source": str(tmp_path), "destination": "/workspace", "mode": "copy"}], + timeout=15.0, + ) + + +# ---- construction ---- + + +def test_missing_timeout_validation(): + with pytest.raises(ValueError, match="positive, finite"): + StrandsShellSandbox(timeout=0) + + +def test_construction_failure_does_not_leak(tmp_path): + # A bad bind source should fail construction; the worker thread is cleaned up. + with pytest.raises(Exception): # noqa: B017 - native error type is opaque + StrandsShellSandbox(binds=[{"source": "/nonexistent/path/xyz", "destination": "/w", "mode": "copy"}]) + + +def test_missing_dependency_raises_helpful_error(monkeypatch): + # Simulate the optional package being absent at construction time. + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "strands_shell": + raise ImportError("No module named 'strands_shell'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match=r"pip install strands-agents\[shell\]"): + StrandsShellSandbox() + + +def test_dropping_sandbox_terminates_worker_thread(): + # The shell factory must not capture `self`, or the worker thread (a GC root) + # would pin the sandbox forever, the finalizer would never fire, and the + # native shell + thread would leak. allowed_urls is the field most likely to + # be captured by accident, so exercise it here. + sandbox = StrandsShellSandbox(timeout=5.0, allowed_urls=["https://example.com/"]) + asyncio.run(sandbox.execute("echo hi")) + finalizer = sandbox._finalizer + assert finalizer.alive + + del sandbox + gc.collect() + + # The worker drains its queue and exits asynchronously; give it a moment. + deadline = time.monotonic() + 5.0 + while finalizer.alive and time.monotonic() < deadline: + time.sleep(0.02) + assert not finalizer.alive, "sandbox was not collected — the worker thread is leaking" + + deadline = time.monotonic() + 5.0 + while any("strands-shell" in t.name for t in threading.enumerate()) and time.monotonic() < deadline: + time.sleep(0.02) + assert not any("strands-shell" in t.name for t in threading.enumerate()), "shell worker thread did not terminate" + + +@pytest.mark.asyncio +async def test_execute_code_captures_stderr(sandbox): + result = await sandbox.execute_code("io.stderr:write('boom\\n')", "lua") + assert "boom" in result.stderr + + +# ---- execute ---- + + +@pytest.mark.asyncio +async def test_execute_runs_command(sandbox): + result = await sandbox.execute("echo hello") + assert result.exit_code == 0 + assert result.stdout == "hello\n" + + +@pytest.mark.asyncio +async def test_execute_reports_nonzero_exit(sandbox): + result = await sandbox.execute("echo oops >&2; exit 3") + assert result.exit_code == 3 + assert "oops" in result.stderr + + +@pytest.mark.asyncio +async def test_session_state_persists_across_calls(sandbox): + await sandbox.execute("export GREETING=hi") + result = await sandbox.execute("echo $GREETING") + assert result.stdout == "hi\n" + + +@pytest.mark.asyncio +async def test_cwd_and_env_are_scoped_to_one_command(sandbox): + # cwd/env apply only to this command and must not leak into session state. + scoped = await sandbox.execute("pwd; echo $SCOPED", cwd="/tmp", env={"SCOPED": "v"}) + assert scoped.stdout == "/tmp\nv\n" + after = await sandbox.execute("pwd; echo [$SCOPED]") + assert "/tmp" not in after.stdout + assert "[]" in after.stdout + + +@pytest.mark.asyncio +async def test_streaming_yields_chunks_then_result(sandbox): + chunks = [c async for c in sandbox.execute_streaming("echo streamed")] + assert isinstance(chunks[-1], ExecutionResult) + assert any(isinstance(c, StreamChunk) and "streamed" in c.data for c in chunks[:-1]) + + +# ---- execute_code ---- + + +@pytest.mark.asyncio +async def test_execute_code_runs_lua(sandbox): + result = await sandbox.execute_code("print(6 * 7)", "lua") + assert result.exit_code == 0 + assert result.stdout.strip() == "42" + + +@pytest.mark.asyncio +async def test_execute_code_rejects_invalid_language(sandbox): + with pytest.raises(ValueError, match="invalid characters"): + await sandbox.execute_code("print(1)", "lua; rm -rf /") + + +@pytest.mark.asyncio +async def test_execute_code_cleans_up_temp_file(sandbox): + await sandbox.execute_code("print(1)", "lua") + listing = await sandbox.list_files("/tmp") + assert not any(f.name.startswith("strands_code_") for f in listing) + + +@pytest.mark.asyncio +async def test_execute_code_reports_write_failure_as_result(sandbox, monkeypatch): + # A failure staging the code must surface as a failed ExecutionResult, not an + # exception escaping the stream (which would break the bash/code tool path). + async def _boom(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(sandbox, "write_file", _boom) + result = await sandbox.execute_code("print(1)", "lua") + assert result.exit_code == 1 + assert "failed to stage code" in result.stderr + + +# ---- file operations ---- + + +@pytest.mark.asyncio +async def test_write_read_round_trip(sandbox): + await sandbox.write_file("/tmp/note.txt", b"content") + assert await sandbox.read_file("/tmp/note.txt") == b"content" + assert await sandbox.read_text("/tmp/note.txt") == "content" + + +@pytest.mark.asyncio +async def test_list_files_reports_metadata(sandbox): + await sandbox.write_file("/tmp/sized.txt", b"12345") + entries = await sandbox.list_files("/tmp") + sized = next(f for f in entries if f.name == "sized.txt") + assert sized.is_dir is False + assert sized.size == 5 + + +@pytest.mark.asyncio +async def test_list_missing_directory_raises(sandbox): + with pytest.raises(SandboxPathNotFoundError): + await sandbox.list_files("/does/not/exist") + + +@pytest.mark.asyncio +async def test_read_missing_file_raises(sandbox): + with pytest.raises(FileNotFoundError): + await sandbox.read_file("/does/not/exist.txt") + + +@pytest.mark.asyncio +async def test_remove_file(sandbox): + await sandbox.write_file("/tmp/gone.txt", b"x") + await sandbox.remove_file("/tmp/gone.txt") + with pytest.raises(FileNotFoundError): + await sandbox.read_file("/tmp/gone.txt") + + +# ---- binds ---- + + +@pytest.mark.asyncio +async def test_copy_bind_exposes_host_files(workspace_sandbox): + result = await workspace_sandbox.execute("cat /workspace/hello.txt") + assert "hello from host" in result.stdout + + +# ---- tools ---- + + +def test_get_tools_returns_prefixed_bash_and_file_editor(sandbox): + names = {t.tool_name for t in sandbox.get_tools()} + assert names == {"sandbox_bash", "sandbox_file_editor"} + + +def test_tool_descriptions_surface_sandbox_config(workspace_sandbox): + bash_tool = next(t for t in workspace_sandbox.get_tools() if t.tool_name == "sandbox_bash") + description = bash_tool.tool_spec["description"] + assert "/workspace" in description + assert "15.0s" in description + + +def test_tool_descriptions_surface_urls_and_credentials(): + sandbox = StrandsShellSandbox( + timeout=10.0, + allowed_urls=["https://api.example.com/"], + credentials=[{"url": "https://api.example.com/", "token": "secret"}], + ) + description = next(t for t in sandbox.get_tools() if t.tool_name == "sandbox_bash").tool_spec["description"] + assert "https://api.example.com/" in description + assert "Credentials are injected automatically" in description + # The secret value itself must never leak into the description. + assert "secret" not in description + + +def test_bare_sandbox_tool_description_has_no_dynamic_suffix(sandbox): + from strands.vended_tools.bash.types import SANDBOX_BASH_DESCRIPTION + + bash_tool = next(t for t in sandbox.get_tools() if t.tool_name == "sandbox_bash") + # Only the timeout line is dynamic for a bare sandbox. + assert bash_tool.tool_spec["description"].startswith(SANDBOX_BASH_DESCRIPTION) + + +@pytest.mark.asyncio +async def test_concurrent_calls_are_safe(sandbox): + results = await asyncio.gather(*[sandbox.execute(f"echo {i}") for i in range(25)]) + assert [r.stdout.strip() for r in results] == [str(i) for i in range(25)] + + +# ---- end-to-end agent integration ---- + + +def test_agent_auto_registers_and_uses_sandbox_tools_end_to_end(workspace_sandbox): + """A model-driven loop creates a file via the editor, then reads it via bash. + + The agent auto-registers the sandbox's vended tools (no explicit ``tools=``). + Uses a scripted mock model so the test is deterministic and provider-free, but + exercises the real tool registration and thread-pinned shell execution. + """ + model = MockedModelProvider( + [ + { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "1", + "name": "sandbox_file_editor", + "input": {"command": "create", "path": "/workspace/out.txt", "file_text": "written"}, + } + } + ], + }, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "2", + "name": "sandbox_bash", + "input": {"command": "cat /workspace/out.txt"}, + } + } + ], + }, + {"role": "assistant", "content": [{"text": "done"}]}, + ] + ) + agent = Agent(model=model, sandbox=workspace_sandbox) + assert {"sandbox_bash", "sandbox_file_editor"} <= set(agent.tool_names) + agent("create and read a file") + + tool_results = [c["toolResult"] for m in agent.messages for c in m["content"] if "toolResult" in c] + assert tool_results[0]["status"] == "success" + assert "created successfully" in tool_results[0]["content"][0]["text"] + assert tool_results[1]["status"] == "success" + # The canonical bash tool returns {"output", "error"}; stdout lands in "output". + assert "written" in str(tool_results[1]["content"]) diff --git a/strands-py/tests/strands/experimental/sandbox/test_worker.py b/strands-py/tests/strands/experimental/sandbox/test_worker.py new file mode 100644 index 0000000000..61f5b6a610 --- /dev/null +++ b/strands-py/tests/strands/experimental/sandbox/test_worker.py @@ -0,0 +1,59 @@ +"""Tests for the thread-pinning worker. + +``_ShellWorker`` confines a thread-unsendable object to a single OS thread. These +tests use a plain sentinel object (no native dependency) to verify the pinning +guarantees: the factory, every submitted call, and the drop all run on the same +thread, factory errors propagate to the constructor, and shutdown is safe. +""" + +import threading + +import pytest + +from strands.experimental.sandbox._worker import _ShellWorker + + +def test_factory_runs_on_worker_thread(): + main_thread = threading.get_ident() + worker = _ShellWorker(lambda: threading.get_ident()) + factory_thread = worker.submit(lambda obj: obj).result() + call_thread = worker.submit(lambda obj: threading.get_ident()).result() + # The object the factory built is the worker thread's id; the call runs there too. + assert factory_thread != main_thread + assert call_thread == factory_thread + worker.shutdown() + + +def test_submitted_calls_receive_the_pinned_object(): + worker = _ShellWorker(lambda: ["state"]) + worker.submit(lambda obj: obj.append("more")).result() + assert worker.submit(lambda obj: list(obj)).result() == ["state", "more"] + worker.shutdown() + + +def test_factory_error_propagates_to_constructor(): + with pytest.raises(RuntimeError, match="boom"): + _ShellWorker(lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + + +def test_call_exception_propagates_through_future(): + worker = _ShellWorker(lambda: object()) + with pytest.raises(ValueError, match="bad"): + worker.submit(lambda obj: (_ for _ in ()).throw(ValueError("bad"))).result() + # Worker survives a failed call and keeps serving. + assert worker.submit(lambda obj: 42).result() == 42 + worker.shutdown() + + +def test_shutdown_is_idempotent(): + worker = _ShellWorker(lambda: object()) + worker.shutdown() + worker.shutdown() # second call must not raise + + +def test_calls_are_serialized_on_one_thread(): + # Every call observes the same thread id, proving single-threaded execution. + worker = _ShellWorker(lambda: object()) + thread_ids = {worker.submit(lambda obj: threading.get_ident()).result() for _ in range(50)} + assert len(thread_ids) == 1 + worker.shutdown() diff --git a/strands-ts/eslint.config.js b/strands-ts/eslint.config.js index 56825ac43b..328bb37e14 100644 --- a/strands-ts/eslint.config.js +++ b/strands-ts/eslint.config.js @@ -27,8 +27,10 @@ export default [ 'src/vended-tools/**/*.ts', 'src/sandbox/docker.ts', 'src/sandbox/ssh.ts', + 'src/experimental/sandbox/strands-shell.ts', 'src/sandbox/__tests__/docker.test.node.ts', 'src/sandbox/__tests__/ssh.test.node.ts', + 'src/experimental/sandbox/__tests__/strands-shell.test.node.ts', ], }), // Then unit-test rules to UTs diff --git a/strands-ts/package.json b/strands-ts/package.json index 2d8c059901..1ac5e19ecf 100644 --- a/strands-ts/package.json +++ b/strands-ts/package.json @@ -128,6 +128,10 @@ "./sandbox/ssh": { "types": "./dist/src/sandbox/ssh.d.ts", "default": "./dist/src/sandbox/ssh.js" + }, + "./experimental/sandbox/strands-shell": { + "types": "./dist/src/experimental/sandbox/strands-shell.d.ts", + "default": "./dist/src/experimental/sandbox/strands-shell.js" } }, "scripts": { @@ -192,6 +196,7 @@ "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/sdk-trace-node": "^2.6.1", "@smithy/types": "^4.0.0", + "@strands-agents/shell": "^0.1.0", "@types/express": "^5.0.6", "@types/node": "^25.6.0", "@types/uuid": "^11.0.0", @@ -254,6 +259,9 @@ "@a2a-js/sdk": { "optional": true }, + "@strands-agents/shell": { + "optional": true + }, "@ai-sdk/provider": { "optional": true }, diff --git a/strands-ts/src/experimental/sandbox/__tests__/strands-shell.test.node.ts b/strands-ts/src/experimental/sandbox/__tests__/strands-shell.test.node.ts new file mode 100644 index 0000000000..977de44e17 --- /dev/null +++ b/strands-ts/src/experimental/sandbox/__tests__/strands-shell.test.node.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StrandsShellSandbox } from '../strands-shell.js' +import { SandboxPathNotFoundError } from '../../../sandbox/errors.js' +import { SANDBOX_BASH_DESCRIPTION } from '../../../vended-tools/bash/types.js' +import { DEFAULT_FILE_EDITOR_DESCRIPTION } from '../../../vended-tools/file-editor/file-editor.js' + +// A fake native shell whose `run` echoes the command it received, so tests can +// assert how StrandsShellSandbox wraps cwd/env and maps output. File ops are backed by +// an in-memory map. +function makeFakeShell() { + const files = new Map() + const runs: string[] = [] + let nextResult: { status: number; stdout: string; stderr: string } | undefined + return { + files, + runs, + setNextResult(r: { status: number; stdout: string; stderr: string }) { + nextResult = r + }, + run: vi.fn(async (command: string) => { + runs.push(command) + const r = nextResult ?? { status: 0, stdout: `ran: ${command}\n`, stderr: '' } + nextResult = undefined + return r + }), + readFile: vi.fn(async (path: string) => { + const data = files.get(path) + if (!data) { + const err = new Error('not found') as Error & { code: string } + err.code = 'ENOENT' + throw err + } + return data + }), + writeFile: vi.fn(async (path: string, content: Uint8Array) => { + files.set(path, content) + }), + removeFile: vi.fn(async (path: string) => { + files.delete(path) + }), + listFiles: vi.fn(async (path: string) => { + if (path === '/missing') { + const err = new Error('not found') as Error & { code: string } + err.code = 'ENOENT' + throw err + } + return [{ name: 'a.txt', isDir: false, size: 3 }] + }), + } +} + +let fakeShell: ReturnType +const createMock = vi.fn(async (_config?: unknown) => fakeShell) + +vi.mock('@strands-agents/shell', () => ({ + Shell: { + create: (config?: unknown) => createMock(config), + }, +})) + +describe('StrandsShellSandbox', () => { + beforeEach(() => { + fakeShell = makeFakeShell() + createMock.mockClear() + }) + + describe('execute', () => { + it('runs a command and maps output', async () => { + fakeShell.setNextResult({ status: 0, stdout: 'hi\n', stderr: '' }) + const sandbox = new StrandsShellSandbox() + const result = await sandbox.execute('echo hi') + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('hi\n') + expect(fakeShell.runs).toStrictEqual(['echo hi']) + }) + + it('maps a non-zero exit code', async () => { + fakeShell.setNextResult({ status: 2, stdout: '', stderr: 'boom\n' }) + const sandbox = new StrandsShellSandbox() + const result = await sandbox.execute('false') + expect(result.exitCode).toBe(2) + expect(result.stderr).toBe('boom\n') + }) + + it('wraps command in a subshell when cwd or env is set', async () => { + const sandbox = new StrandsShellSandbox() + await sandbox.execute('pwd', { cwd: '/workspace', env: { FOO: 'bar' } }) + expect(fakeShell.runs[0]).toBe("( cd '/workspace' && export FOO='bar' && pwd )") + }) + + it('does not wrap the command when no cwd or env is given', async () => { + const sandbox = new StrandsShellSandbox() + await sandbox.execute('ls') + expect(fakeShell.runs[0]).toBe('ls') + }) + + it('creates the native shell only once across calls', async () => { + const sandbox = new StrandsShellSandbox() + await sandbox.execute('echo 1') + await sandbox.execute('echo 2') + expect(createMock).toHaveBeenCalledTimes(1) + }) + + it('passes the config through to Shell.create', async () => { + const config = { timeout: 30, binds: [{ source: '/a', destination: '/b' }] } + const sandbox = new StrandsShellSandbox(config) + await sandbox.execute('echo hi') + expect(createMock).toHaveBeenCalledWith(config) + }) + + it('streams stdout/stderr chunks before the result', async () => { + fakeShell.setNextResult({ status: 0, stdout: 'out', stderr: 'err' }) + const sandbox = new StrandsShellSandbox() + const chunks = [] + for await (const chunk of sandbox.executeStreaming('cmd')) { + chunks.push(chunk) + } + expect(chunks).toStrictEqual([ + { type: 'streamChunk', data: 'out', streamType: 'stdout' }, + { type: 'streamChunk', data: 'err', streamType: 'stderr' }, + { type: 'executionResult', exitCode: 0, stdout: 'out', stderr: 'err', outputFiles: [] }, + ]) + }) + }) + + describe('executeCode', () => { + it('writes code to a temp file, runs the interpreter, and cleans up', async () => { + fakeShell.setNextResult({ status: 0, stdout: '42\n', stderr: '' }) + const sandbox = new StrandsShellSandbox() + const result = await sandbox.executeCode('print(42)', 'lua') + expect(result.stdout).toBe('42\n') + // A temp file was written then removed; the run targeted that file. + expect(fakeShell.writeFile).toHaveBeenCalledTimes(1) + expect(fakeShell.removeFile).toHaveBeenCalledTimes(1) + const writtenPath = fakeShell.writeFile.mock.calls[0]![0] + expect(fakeShell.runs[0]).toBe(`lua ${writtenPath}`) + }) + + it('rejects an invalid interpreter name', async () => { + const sandbox = new StrandsShellSandbox() + await expect(sandbox.executeCode('x', 'lua; rm -rf /')).rejects.toThrow('invalid characters') + }) + + it('removes the temp file even when the interpreter run rejects', async () => { + const sandbox = new StrandsShellSandbox() + fakeShell.run.mockRejectedValueOnce(new Error('kaboom')) + await expect(sandbox.executeCode('print(1)', 'lua')).rejects.toThrow('kaboom') + expect(fakeShell.removeFile).toHaveBeenCalledTimes(1) + }) + + it('reports a failed write to stage code as a result, not a throw', async () => { + const sandbox = new StrandsShellSandbox() + fakeShell.writeFile.mockRejectedValueOnce(new Error('disk full')) + const result = await sandbox.executeCode('print(1)', 'lua') + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('failed to stage code') + // No interpreter run happened, and no temp file to clean up. + expect(fakeShell.run).not.toHaveBeenCalled() + }) + }) + + describe('file operations', () => { + it('round-trips read and write', async () => { + const sandbox = new StrandsShellSandbox() + await sandbox.writeFile('/f.txt', new TextEncoder().encode('data')) + expect(new TextDecoder().decode(await sandbox.readFile('/f.txt'))).toBe('data') + }) + + it('maps a missing directory to SandboxPathNotFoundError', async () => { + const sandbox = new StrandsShellSandbox() + await expect(sandbox.listFiles('/missing')).rejects.toBeInstanceOf(SandboxPathNotFoundError) + }) + + it('returns FileInfo metadata from listFiles', async () => { + const sandbox = new StrandsShellSandbox() + const entries = await sandbox.listFiles('/tmp') + expect(entries).toStrictEqual([{ name: 'a.txt', isDir: false, size: 3 }]) + }) + }) + + describe('getTools', () => { + it('vends bash and fileEditor', () => { + const sandbox = new StrandsShellSandbox() + const names = sandbox.getTools().map((t) => t.name) + expect(names).toStrictEqual(['fileEditor', 'bash']) + }) + + it('surfaces mounts, timeout, urls, and credentials in descriptions', () => { + const sandbox = new StrandsShellSandbox({ + binds: [{ source: '/host', destination: '/workspace', mode: 'copy' }], + timeout: 15, + allowedUrls: ['https://api.example.com/'], + credentials: [{ url: 'https://api.example.com/', token: 'secret' }], + }) + const bashTool = sandbox.getTools().find((t) => t.name === 'bash')! + const description = bashTool.toolSpec.description + expect(description).toContain(SANDBOX_BASH_DESCRIPTION) + expect(description).toContain('/workspace') + expect(description).toContain('15s') + expect(description).toContain('https://api.example.com/') + expect(description).toContain('Credentials are injected automatically') + // The secret value must never leak into the description. + expect(description).not.toContain('secret') + }) + + it('uses the base description for a bare sandbox', () => { + const sandbox = new StrandsShellSandbox() + const editorTool = sandbox.getTools().find((t) => t.name === 'fileEditor')! + expect(editorTool.toolSpec.description).toBe(DEFAULT_FILE_EDITOR_DESCRIPTION) + }) + }) +}) diff --git a/strands-ts/src/experimental/sandbox/strands-shell.ts b/strands-ts/src/experimental/sandbox/strands-shell.ts new file mode 100644 index 0000000000..f8bf16147c --- /dev/null +++ b/strands-ts/src/experimental/sandbox/strands-shell.ts @@ -0,0 +1,318 @@ +/** + * Strands Shell sandbox — runs commands and file operations inside Strands Shell. + * + * {@link StrandsShellSandbox} backs the {@link Sandbox} abstraction with + * [Strands Shell](https://github.com/strands-agents/shell): a Bourne-compatible + * shell that executes entirely in-process, with no `fork`/`exec`/syscalls. The + * agent only reaches what you declare — bound host paths, allowlisted URLs, and + * per-URL credentials it never sees. + * + * This is an **experimental** feature and may change without notice. It requires + * the optional `@strands-agents/shell` peer dependency: + * + * ```sh + * npm install @strands-agents/shell + * ``` + * + * @example + * ```typescript + * import { Agent } from '@strands-agents/sdk' + * import { StrandsShellSandbox } from '@strands-agents/sdk/experimental/sandbox/strands-shell' + * + * const sandbox = new StrandsShellSandbox({ + * binds: [{ source: '/my/project', destination: '/workspace', mode: 'copy' }], + * timeout: 30, + * }) + * const agent = new Agent({ sandbox }) + * await agent.invoke('List the files in /workspace and summarize them') + * ``` + */ + +import { Sandbox } from '../../sandbox/base.js' +import type { ExecuteOptions } from '../../sandbox/base.js' +import { LANGUAGE_PATTERN, shellQuote } from '../../sandbox/constants.js' +import { SandboxPathNotFoundError } from '../../sandbox/errors.js' +import type { ExecutionResult, FileInfo, StreamChunk } from '../../sandbox/types.js' +import type { Tool } from '../../tools/tool.js' +import { makeFileEditor, DEFAULT_FILE_EDITOR_DESCRIPTION } from '../../vended-tools/file-editor/index.js' +import { makeBash, SANDBOX_BASH_DESCRIPTION } from '../../vended-tools/bash/index.js' +import { buildShellEnvPrefix } from '../../sandbox/posix-shell.js' + +/** + * Minimal structural view of the `@strands-agents/shell` runtime API this + * sandbox depends on. Declared locally so the SDK type-checks without the + * optional peer dependency installed; the real module is loaded at runtime. + */ +interface NativeShell { + run(command: string): Promise<{ status: number; stdout: string; stderr: string }> + readFile(path: string): Promise + writeFile(path: string, content: Uint8Array): Promise + removeFile(path: string): Promise + listFiles(path: string): Promise> +} + +interface NativeShellModule { + Shell: { create(config?: ShellSandboxConfig): Promise } +} + +/** A bind-mount entry mapping a host path into the sandbox VFS. */ +export interface ShellBindConfig { + source: string + destination: string + /** `'direct'` passthrough (default) or `'copy'` build-time snapshot. */ + mode?: 'direct' | 'copy' + /** Reject writes through this mount. Default false. */ + readonly?: boolean +} + +/** A per-URL credential injection rule. Exactly one of `token` / `envVar` must be set. */ +export interface ShellCredConfig { + url: string + token?: string + envVar?: string +} + +/** Resource caps. Every field is optional and independently defaulted by the shell. */ +export interface ShellLimits { + maxOutput?: number + maxFileSize?: number + maxFds?: number + maxBgJobs?: number + maxPipeline?: number + maxInput?: number + maxInodes?: number + maxDepth?: number +} + +/** + * Configuration for a {@link StrandsShellSandbox}. Mirrors `@strands-agents/shell`'s + * `ShellConfig`. The agent cannot change this — it is fixed at construction. + */ +export interface ShellSandboxConfig { + binds?: ShellBindConfig[] + credentials?: ShellCredConfig[] + allowedUrls?: string[] + env?: Record + /** File-creation umask. Default 0o022. */ + umask?: number + /** Per-command wall-clock timeout in seconds. `undefined` means no timeout. */ + timeout?: number + limits?: ShellLimits + /** Path to a TOML config file; merges in first, explicit options win. */ + configFile?: string +} + +/** + * A {@link Sandbox} backed by Strands Shell. + * + * File operations use the shell's native VFS API (reporting real `size` + * metadata); command execution runs through the in-process shell, which keeps + * session state (env, working directory, functions) across calls. Code + * execution writes the source to a temporary VFS file and runs the requested + * interpreter against it (Strands Shell ships `lua`; other interpreters are only + * available if present in the sandbox). + * + * The native shell is created lazily on first use because its constructor is + * async; every operation awaits {@link getShell}. Tools vended via + * {@link getTools} describe the sandbox's mounts, timeout, and allowlists so the + * model knows what it can reach. + * + * Note: unlike the base {@link Sandbox} contract, the per-call `timeout` in + * {@link ExecuteOptions} is ignored. Strands Shell enforces a single wall-clock + * timeout configured at construction (`config.timeout`); set it there to bound + * command duration. + */ +export class StrandsShellSandbox extends Sandbox { + private readonly _config: ShellSandboxConfig + private _shellPromise: Promise | undefined + + constructor(config: ShellSandboxConfig = {}) { + super() + this._config = config + } + + /** + * Resolve the underlying native shell, creating it on first call. + * + * @throws If the optional `@strands-agents/shell` package is not installed. + */ + private getShell(): Promise { + if (this._shellPromise === undefined) { + this._shellPromise = this._createShell() + } + return this._shellPromise + } + + private async _createShell(): Promise { + let mod: NativeShellModule + try { + mod = (await import('@strands-agents/shell')) as unknown as NativeShellModule + } catch (err) { + throw new Error( + 'StrandsShellSandbox requires the "@strands-agents/shell" package. Install it with: npm install @strands-agents/shell', + { cause: err } + ) + } + return mod.Shell.create(this._config) + } + + // ---- Command execution ---- + + async *executeStreaming( + command: string, + options?: ExecuteOptions + ): AsyncGenerator { + const shell = await this.getShell() + const output = await shell.run(wrapCommand(command, options)) + yield* emitOutput(output) + } + + async *executeCodeStreaming( + code: string, + language: string, + options?: ExecuteOptions + ): AsyncGenerator { + if (!LANGUAGE_PATTERN.test(language)) { + throw new Error(`language parameter contains invalid characters: ${language}`) + } + const shell = await this.getShell() + // Write the source to a unique temp file in the VFS, then run the interpreter + // against it. This avoids shell-escaping the code and works without a `base64` + // command (which Strands Shell does not provide). + const path = `/tmp/strands_code_${crypto.randomUUID().slice(0, 16)}` + try { + await shell.writeFile(path, new TextEncoder().encode(code)) + } catch (err) { + // A VFS write failure (e.g. inode/size cap) is reported as a failed + // execution rather than thrown, matching how shell-backed sandboxes + // surface failures through the stream. + const message = err instanceof Error ? err.message : String(err) + yield { + type: 'executionResult', + exitCode: 1, + stdout: '', + stderr: `failed to stage code for execution: ${message}`, + outputFiles: [], + } + return + } + let output + try { + output = await shell.run(wrapCommand(`${language} ${path}`, options)) + } finally { + await shell.removeFile(path).catch(() => { + /* best-effort cleanup of the temp file */ + }) + } + yield* emitOutput(output) + } + + // ---- VFS file operations (native) ---- + + async readFile(path: string): Promise { + const shell = await this.getShell() + return shell.readFile(path) + } + + async writeFile(path: string, content: Uint8Array): Promise { + const shell = await this.getShell() + await shell.writeFile(path, content) + } + + async removeFile(path: string): Promise { + const shell = await this.getShell() + await shell.removeFile(path) + } + + async listFiles(path: string): Promise { + const shell = await this.getShell() + try { + const entries = await shell.listFiles(path) + return entries.map((e) => { + const info: { name: string; isDir?: boolean; size?: number } = { name: e.name } + if (e.isDir !== undefined) info.isDir = e.isDir + if (e.size !== undefined) info.size = e.size + return info + }) + } catch (err) { + // Map the shell's missing-path error onto the sandbox contract so the file + // editor and other callers can distinguish absence from failure. + if (isNotFound(err)) { + throw new SandboxPathNotFoundError(path) + } + throw err + } + } + + // ---- Tools ---- + + override getTools(): Tool[] { + const suffix = this._dynamicInfo() + return [ + makeFileEditor(this, { description: `${DEFAULT_FILE_EDITOR_DESCRIPTION}${suffix}` }), + makeBash(this, { description: `${SANDBOX_BASH_DESCRIPTION}${suffix}` }), + ] + } + + /** Human-readable description of the sandbox's reachable surface, or `''`. */ + private _dynamicInfo(): string { + const parts: string[] = [] + const bindDests = (this._config.binds ?? []).map((b) => b.destination).filter(Boolean) + if (bindDests.length > 0) { + parts.push(`Host paths are mounted at: ${bindDests.join(', ')}.`) + parts.push('Writes outside mounted paths are in-memory only and do not reach the host.') + } + if (this._config.timeout !== undefined) { + parts.push(`Commands time out after ${this._config.timeout}s.`) + } + const allowedUrls = this._config.allowedUrls ?? [] + if (allowedUrls.length > 0) { + parts.push(`curl may reach these URL prefixes: ${allowedUrls.join(', ')}.`) + } + const credUrls = (this._config.credentials ?? []).map((c) => c.url).filter(Boolean) + if (credUrls.length > 0) { + parts.push( + `Credentials are injected automatically for: ${credUrls.join(', ')} (do not add auth headers or tokens yourself).` + ) + } + return parts.length > 0 ? ` ${parts.join(' ')}` : '' + } +} + +/** Wrap a command in a subshell applying `cwd`/`env` without leaking session state. */ +function wrapCommand(command: string, options?: ExecuteOptions): string { + const cwd = options?.cwd + const env = options?.env + if (cwd === undefined && (!env || Object.keys(env).length === 0)) { + return command + } + const envPrefix = buildShellEnvPrefix(env) + const cdPrefix = cwd !== undefined ? `cd ${shellQuote(cwd)} && ` : '' + return `( ${cdPrefix}${envPrefix}${command} )` +} + +/** Emit a native shell result as stdout/stderr chunks followed by the final result. */ +function* emitOutput(output: { + status: number + stdout: string + stderr: string +}): Generator { + if (output.stdout) { + yield { type: 'streamChunk', data: output.stdout, streamType: 'stdout' } + } + if (output.stderr) { + yield { type: 'streamChunk', data: output.stderr, streamType: 'stderr' } + } + yield { + type: 'executionResult', + exitCode: output.status, + stdout: output.stdout, + stderr: output.stderr, + outputFiles: [], + } +} + +/** Whether a shell error denotes a missing path (`code === 'ENOENT'`). */ +function isNotFound(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT' +} diff --git a/strands-ts/test/integ/experimental/sandbox/strands-shell.test.node.ts b/strands-ts/test/integ/experimental/sandbox/strands-shell.test.node.ts new file mode 100644 index 0000000000..ae97034011 --- /dev/null +++ b/strands-ts/test/integ/experimental/sandbox/strands-shell.test.node.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'module' +import { StrandsShellSandbox } from '../../../../src/experimental/sandbox/strands-shell.js' +import { SandboxPathNotFoundError } from '../../../../src/sandbox/errors.js' + +// Skips when the optional @strands-agents/shell package is not installed. +function shellAvailable(): boolean { + try { + createRequire(import.meta.url).resolve('@strands-agents/shell') + return true + } catch { + return false + } +} + +describe.skipIf(!shellAvailable())('StrandsShellSandbox (integration)', () => { + it('runs commands and captures stdout, stderr, and exit code', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + + const result = await sandbox.execute('echo hello && echo err >&2') + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('hello\n') + expect(result.stderr).toBe('err\n') + + const failed = await sandbox.execute('exit 42') + expect(failed.exitCode).toBe(42) + }) + + it('persists session state across calls', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + await sandbox.execute('export GREETING=hi') + const result = await sandbox.execute('echo $GREETING') + expect(result.stdout).toBe('hi\n') + }) + + it('scopes cwd and env to a single command', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + const scoped = await sandbox.execute('pwd; echo $SCOPED', { cwd: '/tmp', env: { SCOPED: 'v' } }) + expect(scoped.stdout).toBe('/tmp\nv\n') + const after = await sandbox.execute('pwd; echo [$SCOPED]') + expect(after.stdout).not.toContain('/tmp') + expect(after.stdout).toContain('[]') + }) + + it('runs code through the lua interpreter', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + const result = await sandbox.executeCode('print(6 * 7)', 'lua') + expect(result.exitCode).toBe(0) + expect(result.stdout.trim()).toBe('42') + }) + + it('cleans up the temp file after executeCode', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + await sandbox.executeCode('print(1)', 'lua') + const entries = await sandbox.listFiles('/tmp') + expect(entries.some((e) => e.name.startsWith('strands_code_'))).toBe(false) + }) + + it('round-trips native file operations with metadata', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + await sandbox.writeFile('/tmp/note.txt', new TextEncoder().encode('content')) + expect(new TextDecoder().decode(await sandbox.readFile('/tmp/note.txt'))).toBe('content') + const entries = await sandbox.listFiles('/tmp') + const note = entries.find((e) => e.name === 'note.txt')! + expect(note.isDir).toBe(false) + expect(note.size).toBe(7) + }) + + it('throws SandboxPathNotFoundError for a missing directory', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + await expect(sandbox.listFiles('/does/not/exist')).rejects.toBeInstanceOf(SandboxPathNotFoundError) + }) + + it('exposes host files through a copy bind mount', async () => { + const dir = mkdtempSync(join(tmpdir(), 'strands-shell-integ-')) + writeFileSync(join(dir, 'hello.txt'), 'hello from host') + const sandbox = new StrandsShellSandbox({ + binds: [{ source: dir, destination: '/workspace', mode: 'copy' }], + timeout: 15, + }) + const result = await sandbox.execute('cat /workspace/hello.txt') + expect(result.stdout).toContain('hello from host') + }) + + it('vends bash and fileEditor tools that operate on the sandbox', async () => { + const sandbox = new StrandsShellSandbox({ timeout: 15 }) + const tools = sandbox.getTools() + expect(tools.map((t) => t.name).sort()).toStrictEqual(['bash', 'fileEditor']) + }) +})