diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 4ddab0956..5db25e4bf 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -688,9 +688,16 @@ assert backend.grep("print", "/tmp", glob="*.py").matches[0]["line"] == 1 middleware = backend.create_middleware() # Shares the same virtual filesystem ``` -Uses the structured Deep Agents 0.7.13+ backend protocol. Shell execution reports -native output truncation; file operations preserve exact VFS contents. Configure -execution timeouts on the backend constructor. +Uses the structured Deep Agents 0.7.32+ backend protocol. Shell execution reports +native output truncation; file operations preserve exact VFS contents. The +constructor `timeout_seconds` is the execution budget; a per-command `timeout` +from the agent can shorten it but never extend it. Images, PDFs and other +binary files are read as base64 (up to 500 KB) so multimodal models can see +them, and upload/download failures use the protocol's error codes +(`file_not_found`, `is_directory`, `permission_denied`, `invalid_path`). + +`Bash`/`BashTool` `execute*` methods accept the same per-call cap directly: +`execute_sync("make test", timeout_seconds=10)`. ## API Reference diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index d8f94fd19..e412f2b64 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -614,6 +614,7 @@ class Bash: capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, + timeout_seconds: float | None = None, ) -> ExecResult: """Execute bash commands asynchronously. @@ -621,6 +622,8 @@ class Bash: commands: Bash script to run (like ``bash -c "commands"``). on_output: Optional callback receiving chunked ``(stdout, stderr)`` pairs during execution. Must be synchronous. + timeout_seconds: Optional wall-clock cap for this call only. It can + only tighten the constructor ``timeout_seconds``, never extend it. Async ``custom_builtins`` callbacks run on the caller's active asyncio loop. @@ -638,7 +641,9 @@ class Bash: """ ... - def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def execute_sync( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute bash commands synchronously (blocking). Async external handlers use the instance's private worker loop. @@ -658,7 +663,9 @@ class Bash: """ ... - async def execute_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + async def execute_or_throw( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute commands asynchronously; raise ``BashError`` on non-zero exit. ``on_output`` must be synchronous. @@ -675,7 +682,9 @@ class Bash: """ ... - def execute_sync_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def execute_sync_or_throw( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute commands synchronously; raise ``BashError`` on non-zero exit. ``on_output`` must be synchronous. @@ -1251,6 +1260,7 @@ class BashTool: capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, + timeout_seconds: float | None = None, ) -> ExecResult: """Execute bash commands asynchronously. @@ -1268,7 +1278,9 @@ class BashTool: """ ... - def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def execute_sync( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute bash commands synchronously (blocking). Async ``custom_builtins`` callbacks run on a private loop here. @@ -1288,7 +1300,9 @@ class BashTool: """ ... - async def execute_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + async def execute_or_throw( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute commands asynchronously; raise ``BashError`` on non-zero exit. ``on_output`` must be synchronous. @@ -1302,7 +1316,9 @@ class BashTool: """ ... - def execute_sync_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def execute_sync_or_throw( + self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + ) -> ExecResult: """Execute commands synchronously; raise ``BashError`` on non-zero exit. ``on_output`` must be synchronous. diff --git a/crates/bashkit-python/bashkit/deepagents.py b/crates/bashkit-python/bashkit/deepagents.py index bcf094261..666557939 100644 --- a/crates/bashkit-python/bashkit/deepagents.py +++ b/crates/bashkit-python/bashkit/deepagents.py @@ -29,6 +29,7 @@ from __future__ import annotations import asyncio +import base64 import posixpath import threading import time @@ -56,6 +57,27 @@ _MAX_OPERATION_BYTES = 10_000_000 _DEFAULT_GREP_MATCHES = 1_000 _MAX_GREP_RESULT_BYTES = 100_000 +# Mirrors deepagents' sandbox binary read cap (`MAX_BINARY_BYTES`): images, PDFs +# and other binary files are returned base64-encoded up to this size. +_MAX_BINARY_READ_BYTES = 500 * 1024 + + +def _file_error_code(exc: Exception) -> str: + """Map a native VFS error to deepagents' `FileOperationError` codes. + + The protocol standardizes these so the model can act on them; anything + unrecognized keeps the native message. + """ + message = str(exc).lower() + if "is a directory" in message: + return "is_directory" + if "not found" in message or "no such file" in message or "not a directory" in message: + return "file_not_found" + if "permission" in message or "read-only" in message or "readonly" in message: + return "permission_denied" + if "invalid path" in message or "path traversal" in message: + return "invalid_path" + return str(exc) class _BudgetExceeded(Exception): @@ -103,7 +125,7 @@ def _lines(content: str): SandboxBackendProtocol, WriteResult, ) - from deepagents.backends.utils import compile_grep_include_glob + from deepagents.backends.utils import _get_backend_read_file_type, compile_grep_include_glob from langchain.agents.middleware.types import AgentMiddleware from langchain_core.tools import StructuredTool @@ -304,15 +326,24 @@ def create_middleware(self) -> BashkitMiddleware: # parsing must never silently corrupt file contents or discovered paths. # Async protocol defaults dispatch these sync methods off the event loop. - # Deep Agents explicitly detects backends without per-call timeout support. - # Keep the keyword absent so the framework uses our constructor timeout. - def execute(self, command: str) -> ExecuteResponse: # type: ignore[override] - result = self._bash.execute_sync(command) + # Decision: a per-call `timeout` only tightens the constructor + # `timeout_seconds` (native `ExecOptions::timeout` takes the minimum), + # so a model-chosen value can never extend the sandbox budget. `None` + # and `0` (Deep Agents' "no timeout" hint) both keep the configured + # limit. + @staticmethod + def _timeout_seconds(timeout: int | None) -> float | None: + if timeout is None or timeout <= 0: + return None + return float(timeout) + + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + result = self._bash.execute_sync(command, timeout_seconds=self._timeout_seconds(timeout)) return self._execute_response(result) - async def aexecute(self, command: str) -> ExecuteResponse: # type: ignore[override] + async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109 # Preserve caller-loop clients and ContextVars in runtime callbacks. - result = await self._bash.execute(command) + result = await self._bash.execute(command, timeout_seconds=self._timeout_seconds(timeout)) return self._execute_response(result) @staticmethod @@ -362,6 +393,8 @@ def read( try: path = self._path(file_path) metadata = self._bash.stat(path) + if _get_backend_read_file_type(path) != "text": + return self._read_binary(file_path, path, metadata["size"]) budget = self._budget(_cancelled) self._charge(budget, files=1, size=metadata["size"]) selected = [] @@ -373,7 +406,13 @@ def read( selected.append(line) except _BudgetExceeded: return ReadResult(error="read resource budget exceeded") + except UnicodeDecodeError: + # Text-by-extension file holding non-UTF-8 bytes: return it as + # base64 like Deep Agents' own backends instead of failing. + return self._read_binary(file_path, path, metadata["size"]) except (RuntimeError, ValueError) as exc: + if "utf-8" in str(exc).lower(): + return self._read_binary(file_path, path, metadata["size"]) return ReadResult(error=str(exc)) start_line = end_line = total_lines = next_offset = None if selected: @@ -389,6 +428,19 @@ def read( next_offset=next_offset, ) + def _read_binary(self, file_path: str, path: str, size: int) -> ReadResult: + """Return a whole binary file base64-encoded (multimodal reads).""" + if size > _MAX_BINARY_READ_BYTES: + limit = _MAX_BINARY_READ_BYTES + return ReadResult( + error=f"File '{file_path}': Binary file exceeds maximum preview size of {limit} bytes" + ) + try: + raw = self._bash.fs().read_file(path) + except (RuntimeError, ValueError) as exc: + return ReadResult(error=str(exc)) + return ReadResult(file_data={"content": base64.b64encode(raw).decode("ascii"), "encoding": "base64"}) + async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult: return await self._run_cancellable(self.read, file_path, offset, limit) @@ -533,7 +585,7 @@ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: content = self._bash.fs().read_file(self._path(path)) responses.append(FileDownloadResponse(path=path, content=content)) except (RuntimeError, ValueError) as exc: - responses.append(FileDownloadResponse(path=path, error=str(exc))) + responses.append(FileDownloadResponse(path=path, error=_file_error_code(exc))) return responses def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: @@ -545,7 +597,7 @@ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadRespons self._bash.fs().write_file(absolute, content) responses.append(FileUploadResponse(path=path)) except (RuntimeError, ValueError) as exc: - responses.append(FileUploadResponse(path=path, error=str(exc))) + responses.append(FileUploadResponse(path=path, error=_file_error_code(exc))) return responses # === Utility === diff --git a/crates/bashkit-python/pyproject.toml b/crates/bashkit-python/pyproject.toml index 1f721902a..ca3e8c433 100644 --- a/crates/bashkit-python/pyproject.toml +++ b/crates/bashkit-python/pyproject.toml @@ -35,7 +35,7 @@ Changelog = "https://github.com/everruns/bashkit/releases" [project.optional-dependencies] langchain = ["langchain-core>=0.3", "langchain-anthropic>=0.3"] -deepagents = ["deepagents>=0.7.13", "langchain-anthropic>=0.3"] +deepagents = ["deepagents>=0.7.32", "langchain-anthropic>=0.3"] pydantic-ai = ["pydantic-ai>=1.0"] dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "pytest-timeout>=2.2"] diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index bd747636b..5356ac7d1 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -4379,6 +4379,7 @@ async fn exec_bash_with_optional_output( commands: &str, on_output: Option, builtin_session: Option>, + timeout: Option, ) -> PyResult { let mut execution_extensions = ExecutionExtensions::new(); if let Some(session) = builtin_session { @@ -4398,9 +4399,13 @@ async fn exec_bash_with_optional_output( callback_requested_cancel.clone(), callback_error.clone(), ); - let result = bash - .exec_streaming_with_extensions(commands, output_callback, execution_extensions) - .await; + let mut options = bashkit::ExecOptions::new() + .streaming(output_callback) + .extensions(execution_extensions); + if let Some(timeout) = timeout { + options = options.timeout(timeout); + } + let result = bash.exec_with_options(commands, options).await; if let Some(err) = take_output_handler_error(&callback_error) { if callback_requested_cancel.load(Ordering::Relaxed) { cancelled.store(false, Ordering::Relaxed); @@ -4409,8 +4414,11 @@ async fn exec_bash_with_optional_output( } result } else { - bash.exec_with_extensions(commands, execution_extensions) - .await + let mut options = bashkit::ExecOptions::new().extensions(execution_extensions); + if let Some(timeout) = timeout { + options = options.timeout(timeout); + } + bash.exec_with_options(commands, options).await }; let converted = py_exec_result_from_bash_result(result); @@ -5147,7 +5155,9 @@ impl PyBash { /// pyo3-async-runtimes, which the WebAssembly (Pyodide) build omits. On wasm, /// use `execute_sync()`. See knowledge/runtimes/emscripten-wheels.md. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None))] + // Python keyword arguments map 1:1 onto parameters here. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None))] fn execute<'py>( &self, py: Python<'py>, @@ -5156,7 +5166,9 @@ impl PyBash { capture: Option, stdout: Option>, stderr: Option>, + timeout_seconds: Option, ) -> PyResult> { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_reentry()?; let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); if live_output && on_output.is_some() { @@ -5196,11 +5208,18 @@ impl PyBash { stdout, stderr, builtin_session.expect("live output session"), + timeout, ) .await } else { - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await + exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await } })?; if let Some(cancel_session) = cancel_session { @@ -5224,13 +5243,15 @@ impl PyBash { /// same `Bash` instance. If the lock cannot be acquired within the timeout, /// a `RuntimeError` is raised. For concurrent workloads, use separate `Bash` /// instances per thread or use the async `execute()` method. - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_sync( &self, py: Python<'_>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_sync_access()?; let builtin_session = capture_custom_builtin_session( py, @@ -5259,21 +5280,28 @@ impl PyBash { )); } }; - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await + exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await }) }) } /// Execute commands synchronously. Raises `BashError` on non-zero exit. - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_sync_or_throw( &self, py: Python<'_>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult { - let result = self.execute_sync(py, commands, on_output)?; + let result = self.execute_sync(py, commands, on_output, timeout_seconds)?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } @@ -5284,13 +5312,15 @@ impl PyBash { /// /// Native-only (see `execute`). Use `execute_sync_or_throw()` on wasm. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_or_throw<'py>( &self, py: Python<'py>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult> { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_reentry()?; let builtin_session = capture_custom_builtin_session( py, @@ -5304,9 +5334,14 @@ impl PyBash { let cancel_session = builtin_session.clone(); let future = future_into_py(py, async move { let mut bash = inner.lock().await; - let result = - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await?; + let result = exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } @@ -6359,7 +6394,9 @@ impl BashTool { /// Native-only (see `Bash.execute`). Use `execute_sync()` on wasm. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None))] + // Python keyword arguments map 1:1 onto parameters here. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None))] fn execute<'py>( &self, py: Python<'py>, @@ -6368,7 +6405,9 @@ impl BashTool { capture: Option, stdout: Option>, stderr: Option>, + timeout_seconds: Option, ) -> PyResult> { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_reentry()?; let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); if live_output && on_output.is_some() { @@ -6408,11 +6447,18 @@ impl BashTool { stdout, stderr, builtin_session.expect("live output session"), + timeout, ) .await } else { - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await + exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await } })?; if let Some(cancel_session) = cancel_session { @@ -6428,13 +6474,15 @@ impl BashTool { /// /// Acquires async mutex with 30-second timeout. For concurrent workloads, /// use separate `BashTool` instances per thread or the async `execute()`. - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_sync( &self, py: Python<'_>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_sync_access()?; let builtin_session = capture_custom_builtin_session( py, @@ -6462,21 +6510,28 @@ impl BashTool { )); } }; - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await + exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await }) }) } /// Execute commands synchronously. Raises `BashError` on non-zero exit. - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_sync_or_throw( &self, py: Python<'_>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult { - let result = self.execute_sync(py, commands, on_output)?; + let result = self.execute_sync(py, commands, on_output, timeout_seconds)?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } @@ -6487,13 +6542,15 @@ impl BashTool { /// /// Native-only (see `Bash.execute`). Use `execute_sync_or_throw()` on wasm. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] fn execute_or_throw<'py>( &self, py: Python<'py>, commands: String, on_output: Option>, + timeout_seconds: Option, ) -> PyResult> { + let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; self.reject_external_handler_reentry()?; let builtin_session = capture_custom_builtin_session( py, @@ -6507,9 +6564,14 @@ impl BashTool { let cancel_session = builtin_session.clone(); let future = future_into_py(py, async move { let mut bash = inner.lock().await; - let result = - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) - .await?; + let result = exec_bash_with_optional_output( + &mut bash, + &commands, + on_output, + builtin_session, + timeout, + ) + .await?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } diff --git a/crates/bashkit-python/src/streaming.rs b/crates/bashkit-python/src/streaming.rs index 7a1c88a16..03377b284 100644 --- a/crates/bashkit-python/src/streaming.rs +++ b/crates/bashkit-python/src/streaming.rs @@ -210,13 +210,16 @@ pub(crate) async fn execute( stdout: Option>, stderr: Option>, session: Arc, + timeout: Option, ) -> PyResult { session.check_cancelled()?; let mut extensions = ExecutionExtensions::new(); let _ = extensions.insert(session.clone()); - let mut execution = bash - .execute(script) - .options(bashkit::ExecOptions::new().extensions(extensions)); + let mut options = bashkit::ExecOptions::new().extensions(extensions); + if let Some(timeout) = timeout { + options = options.timeout(timeout); + } + let mut execution = bash.execute(script).options(options); if let Some(limit) = capture { execution = execution.capture(limit); } diff --git a/crates/bashkit-python/tests/test_deepagents_backend.py b/crates/bashkit-python/tests/test_deepagents_backend.py index 01f3a0602..9271bc650 100644 --- a/crates/bashkit-python/tests/test_deepagents_backend.py +++ b/crates/bashkit-python/tests/test_deepagents_backend.py @@ -1,5 +1,8 @@ """Exercise the real Deep Agents protocol against Bashkit's native VFS.""" +import base64 +import time + import pytest pytest.importorskip("deepagents") @@ -8,6 +11,10 @@ from bashkit.deepagents import BashkitBackend # noqa: E402 +_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +) + @pytest.fixture def backend(): @@ -160,6 +167,8 @@ def bind_tools(self, tools, **kwargs): ("glob", {"pattern": "*.py", "path": "/tmp"}), ("ls", {"path": "/tmp"}), ("execute", {"command": "echo framework-execute"}), + ("execute", {"command": "sleep 5; echo late", "timeout": 1}), + ("read_file", {"file_path": "/tmp/pixel.png"}), ] model = ScriptedModel( responses=[ @@ -168,17 +177,25 @@ def bind_tools(self, tools, **kwargs): ] + [AIMessage(content="done")] ) + backend.upload_files([("/tmp/pixel.png", _PNG)]) agent = create_deep_agent(model=model, backend=backend) response = agent.invoke({"messages": [{"role": "user", "content": "Exercise filesystem tools"}]}) messages = [message for message in response["messages"] if message.type == "tool"] assert len(messages) == len(calls) - assert all(message.status == "success" for message in messages) + assert all(message.status == "success" for message in messages[:6]) assert "needle" in str(messages[1].content) assert "/tmp/protocol.py" in str(messages[2].content) - assert "framework-execute" in str(messages[-1].content) + assert "framework-execute" in str(messages[5].content) + # Per-call timeout is honored, not rejected as unsupported. + assert "per-command timeout" not in str(messages[6].content) + assert "late" not in str(messages[6].content) + assert "timeout" in str(messages[6].content) + # Images reach the model as multimodal content, not a UTF-8 error. + assert "Invalid UTF-8" not in str(messages[7].content) + assert "image" in str(messages[7].content) from deepagents.backends.protocol import execute_accepts_timeout - assert not execute_accepts_timeout(type(backend)) + assert execute_accepts_timeout(type(backend)) def test_binary_transfers_and_large_exact_file(backend): @@ -195,3 +212,45 @@ def test_binary_transfers_and_large_exact_file(backend): assert backend.read("/tmp/large.txt").file_data["content"] == text assert backend.edit("/tmp/large.txt", text, "short").error is None assert backend.download_files(["/tmp/large.txt"])[0].content == b"short" + + +def test_execute_per_call_timeout_only_tightens(): + backend = BashkitBackend(timeout_seconds=0.5) + started = time.monotonic() + response = backend.execute("export KEPT=1; sleep 5; echo late", timeout=60) + # The model's larger value cannot extend the configured 0.5 s budget. + assert time.monotonic() - started < 3 + assert "late" not in response.output and response.exit_code != 0 + assert "timeout" in response.output + started = time.monotonic() + response = BashkitBackend().execute("sleep 5; echo late", timeout=1) + assert time.monotonic() - started < 3 and "late" not in response.output + # 0 / None keep the configured limit; state survives a timed-out call. + assert backend.execute("echo $KEPT", timeout=0).output == "1\n" + assert backend.execute("echo ok", timeout=None).output == "ok\n" + + +async def test_aexecute_per_call_timeout(backend): + response = await backend.aexecute("sleep 5; echo late", timeout=1) + assert "late" not in response.output and response.exit_code != 0 + assert (await backend.aexecute("echo ok", timeout=5)).output == "ok\n" + + +def test_read_binary_files_as_base64(backend): + assert backend.upload_files([("/tmp/pixel.png", _PNG)])[0].error is None + result = backend.read("/tmp/pixel.png") + assert result.error is None + assert result.file_data == {"content": base64.b64encode(_PNG).decode("ascii"), "encoding": "base64"} + # Text-by-extension file with invalid UTF-8 falls back to base64. + backend.upload_files([("/tmp/latin1.txt", b"caf\xe9")]) + assert backend.read("/tmp/latin1.txt").file_data["encoding"] == "base64" + # Oversized binaries return an error instead of a huge payload. + backend.upload_files([("/tmp/big.png", b"\x89" * (500 * 1024 + 1))]) + assert "exceeds maximum preview size" in backend.read("/tmp/big.png").error + assert backend.read("/tmp/missing.png").error + + +def test_transfer_errors_use_protocol_codes(backend): + assert backend.download_files(["/missing"])[0].error == "file_not_found" + assert backend.download_files(["/tmp"])[0].error == "is_directory" + assert backend.upload_files([("/tmp", b"no")])[0].error == "is_directory" diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 4fc971a22..60c89118f 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -703,6 +703,7 @@ pub struct ExecOptions { positional: Option>, stdin: Option, stream_output: Option>, + timeout: Option, } impl ExecOptions { @@ -782,6 +783,33 @@ impl ExecOptions { self.stdin = Some(stdin.into()); self } + + /// Cap this execution's wall-clock time at `timeout`. + /// + /// The cap can only tighten the configured + /// [`ExecutionLimits::timeout`](crate::ExecutionLimits::timeout), never + /// extend it: the effective budget is the smaller of the two, so an + /// untrusted caller (for example an LLM picking a per-command timeout) + /// cannot raise the sandbox's limit. Expiry fails the call with the same + /// [`LimitExceeded::Timeout`] error as the + /// configured limit and leaves the session usable. + /// + /// ```no_run + /// # use bashkit::{Bash, ExecOptions}; + /// # use std::time::Duration; + /// # async fn run() -> bashkit::Result<()> { + /// let mut bash = Bash::new(); + /// let result = bash + /// .exec_with_options("sleep 5", ExecOptions::new().timeout(Duration::from_millis(100))) + /// .await; + /// assert!(result.is_err()); + /// # Ok(()) + /// # } + /// ``` + pub fn timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = Some(timeout); + self + } } /// Per-invocation interpreter state carried from [`ExecOptions`] to the point @@ -971,6 +999,7 @@ impl Bash { positional, stdin, stream_output, + timeout, } = options; let invocation = Invocation { arg0, @@ -994,7 +1023,13 @@ impl Bash { self.interpreter.bind_command_hash_budget()?; // Expose active execution limits and deadline to builtins that need to // honor per-execution sandbox settings inside synchronous VM sections. - let active_limits = self.interpreter.limits().clone(); + // Per-call timeout only tightens the configured limit (see + // `ExecOptions::timeout`). + let execution_timeout = timeout.map_or(self.interpreter.limits().timeout, |t| { + t.min(self.interpreter.limits().timeout) + }); + let mut active_limits = self.interpreter.limits().clone(); + active_limits.timeout = execution_timeout; let _ = extensions.insert(active_limits.clone()); let _ = extensions.insert(self.interpreter.execution_budget().clone()); let _ = extensions.insert(builtins::ExecutionDeadline::new(active_limits.timeout)); @@ -1011,7 +1046,7 @@ impl Bash { let _stream_guard = output_callback.map(|cb| OutputCallbackGuard::install(&mut self.interpreter, cb)); let extensions_guard = self.interpreter.scoped_execution_extensions(extensions); - let mut result = self.exec_impl(script, invocation).await; + let mut result = self.exec_impl(script, invocation, execution_timeout).await; let cleanup = extensions_guard.finish(); if let Ok(exec_result) = &mut result { exec_result.capability_cleanup = cleanup; @@ -1029,7 +1064,12 @@ impl Bash { result } - async fn exec_impl(&mut self, script: &str, invocation: Invocation) -> Result { + async fn exec_impl( + &mut self, + script: &str, + invocation: Invocation, + execution_timeout: std::time::Duration, + ) -> Result { // THREAT[TM-ISO-005/006/007]: Reset transient state between exec() calls self.interpreter.reset_transient_state(); #[cfg(feature = "terminal")] @@ -1262,7 +1302,6 @@ impl Bash { let exec_start = crate::time_compat::Instant::now(); // THREAT[TM-DOS-057]: Wrap execution with a host-backed timeout to // prevent sleep and pending async callbacks from bypassing the budget. - let execution_timeout = self.interpreter.limits().timeout; #[cfg(feature = "terminal")] let timed = { let clock = self.input_wait_clock.clone(); diff --git a/crates/bashkit/tests/integration/exec_options_tests.rs b/crates/bashkit/tests/integration/exec_options_tests.rs index c92619453..91cccc5ea 100644 --- a/crates/bashkit/tests/integration/exec_options_tests.rs +++ b/crates/bashkit/tests/integration/exec_options_tests.rs @@ -267,3 +267,48 @@ async fn set_dashdash_keeps_a_supplied_arg0() { .unwrap(); assert_eq!(result.stdout, "run.sh x\n"); } + +#[tokio::test] +async fn exec_with_options_timeout_caps_one_call_and_session_recovers() { + use std::time::{Duration, Instant}; + let mut bash = Bash::new(); + let started = Instant::now(); + let result = bash + .exec_with_options( + "export KEPT=1; sleep 5", + ExecOptions::new().timeout(Duration::from_millis(100)), + ) + .await; + assert!(started.elapsed() < Duration::from_secs(3)); + match result { + Err(bashkit::Error::ResourceLimit(bashkit::LimitExceeded::Timeout(t))) => { + assert_eq!(t, Duration::from_millis(100)); + } + other => panic!("expected per-call timeout, got {other:?}"), + } + // The cap applies to that call only: the next call uses the session limit. + let result = bash + .exec_with_options("sleep 0.2; echo ok", ExecOptions::new()) + .await + .unwrap(); + assert_eq!(result.stdout, "ok\n"); +} + +#[tokio::test] +async fn exec_with_options_timeout_never_extends_configured_limit() { + use std::time::Duration; + let limits = bashkit::ExecutionLimits::default().timeout(Duration::from_millis(100)); + let mut bash = Bash::builder().limits(limits).build(); + let result = bash + .exec_with_options( + "sleep 5", + ExecOptions::new().timeout(Duration::from_secs(60)), + ) + .await; + match result { + Err(bashkit::Error::ResourceLimit(bashkit::LimitExceeded::Timeout(t))) => { + assert_eq!(t, Duration::from_millis(100)); + } + other => panic!("expected configured timeout to win, got {other:?}"), + } +} diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index 516263045..062d27532 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -41,8 +41,16 @@ See `crates/bashkit/src/lib.rs` for the full public API surface. ### Per-invocation exec state `ExecOptions` carries everything scoped to a single `exec_with_options` -call: streaming callback, builtin extensions, `arg0`, `positional`, and -`stdin`. All of it is per-call by construction, not session state. +call: streaming callback, builtin extensions, `arg0`, `positional`, +`stdin`, and `timeout`. All of it is per-call by construction, not session +state. + +- `timeout` only tightens `ExecutionLimits::timeout`: the call runs under + the smaller of the two, enforced by the same host-backed wrapper and + reported as the same `LimitExceeded::Timeout`, so a caller-chosen value + (an LLM tool argument) can never extend the sandbox budget. The + `ExecutionDeadline` extension handed to builtins carries the effective + value. - Positional parameters exist only inside a call frame, so the host boundary pushes a synthetic top-level frame before diff --git a/knowledge/log.md b/knowledge/log.md index ef673b1f9..af65c518a 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,10 @@ # Bashkit Knowledge Update Log +## 2026-11-02 + +* **Per-call execution timeout**: `ExecOptions::timeout` caps one `exec_with_options` call at the smaller of it and `ExecutionLimits::timeout`; Python `execute*` methods expose it as `timeout_seconds`. See [Architecture](foundations/architecture.md#per-invocation-exec-state). +* **Deep Agents backend refresh**: `BashkitBackend` targets Deep Agents 0.7.32+, accepts the per-command `timeout`, reads binary files as base64, and returns protocol error codes for transfers. See [Python Package](runtimes/python-package.md#deep-agents-backend). + ## 2026-11-01 * **CI nightly pin**: Every nightly job (fuzz compile check, scheduled fuzz, Miri, ASan build-std) is pinned to `nightly-2026-10-30`. Nightly 2026-10-31 (rustc f9ceb4a4d) rejects the `bashkit` lib with E0391, an async-drop query cycle on `parser::ast::CommandList`; reproduced on unmodified `main`, and the 2026-10-30 nightly builds it. Unpin when a later nightly passes `cargo +nightly check -p bashkit --lib`. diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 4e0b31e0b..29142993a 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -438,14 +438,20 @@ invocation regardless of mechanism. ## Deep Agents backend -The optional `deepagents` extra requires Deep Agents 0.7.13 or later and Python +The optional `deepagents` extra requires Deep Agents 0.7.32 or later and Python 3.11+ (the upstream framework minimum); the base package remains Python 3.9+. CI installs the extra on Python 3.11–3.14 so protocol tests cannot silently skip. `BashkitBackend` implements its structured `read`, `ls`, `glob`, `grep`, `write`, `edit`, and `delete` results; obsolete `ls_info`, `glob_info`, and `grep_raw` are removed. -`execute` propagates either native stream's truncation flag. Timeout policy -remains configured on construction (`timeout_seconds`); per-call timeout -support is not advertised to the framework. +`execute` propagates either native stream's truncation flag. The constructor +`timeout_seconds` is the execution budget. `execute`/`aexecute` accept Deep +Agents' per-call `timeout` and pass it as the native `timeout_seconds` +(`ExecOptions::timeout`), which only tightens the budget; `None` and `0` (the +framework's "no timeout" hint) keep the configured limit. Non-text files (by +Deep Agents' extension classifier) and text files with invalid UTF-8 read as +whole-file base64 `FileData` capped at 500 KB, matching upstream sandboxes, so +multimodal reads work. Upload/download failures map to the protocol's +`FileOperationError` codes, with the native message kept for anything else. File operations use the live native VFS directly, avoiding shell output caps, newline insertion, option injection, and colon-delimited path parsing. They diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index bc52f53eb..03bf97aa2 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -275,7 +275,7 @@ on default 2 MiB test threads (TM-DOS-044). | TM-DOS-054 | `glob --files` inherits ExtGlob blowup | `glob --files "+(a|aa)" /dir` dispatches to `glob_match` with same exponential cost as TM-DOS-031 | Same as TM-DOS-031, `glob_match_impl` recursion-depth cap covers `glob --files` callers | **MITIGATED** | | TM-DOS-055 | `split` file count amplification | `split -l 1 bigfile` creates one output file per line; bounded by `max_file_count` FS limit | FS limits (TM-DOS-006) | **MITIGATED** | | TM-DOS-056 | `source` self-recursion stack overflow | Script that sources itself recurses unboundedly | `source` shares the function call-depth counter; self-/mutual recursion hits `max_function_depth` with a clean error, not SIGABRT | **MITIGATED** | -| TM-DOS-057 | `sleep` bypasses execution timeout | `sleep`, `(sleep N)`, `echo x \| sleep N`, `sleep N & wait`, `timeout N sleep N` all ignore `ExecutionLimits::timeout` | `Bash::exec_impl` wraps execution in `time_compat::timeout(limits.timeout, …)` on every target; native uses tokio and JS-host wasm uses `setTimeout` through `gloo-timers`. Non-JS wasm clamps its blocking sleep spin to the shared execution deadline and checks expiry after every interpreter poll, including a ready result. Synchronous wasm CPU work cannot otherwise yield to a host timer, so command, loop, parser-fuel, and memory limits remain its deterministic backstop. Browser regressions: `sleep yields to the host wall clock`, `timeout enforces a host wall-clock deadline`, `options: timeoutMs bounds a pending async builtin`; native unit coverage verifies the non-JS clamp. | **MITIGATED** | +| TM-DOS-057 | `sleep` bypasses execution timeout | `sleep`, `(sleep N)`, `echo x \| sleep N`, `sleep N & wait`, `timeout N sleep N` all ignore `ExecutionLimits::timeout` | `Bash::exec_impl` wraps execution in `time_compat::timeout(limits.timeout, …)` on every target (a per-call `ExecOptions::timeout` is applied as `min` with it, so callers can only tighten the budget); native uses tokio and JS-host wasm uses `setTimeout` through `gloo-timers`. Non-JS wasm clamps its blocking sleep spin to the shared execution deadline and checks expiry after every interpreter poll, including a ready result. Synchronous wasm CPU work cannot otherwise yield to a host timer, so command, loop, parser-fuel, and memory limits remain its deterministic backstop. Browser regressions: `sleep yields to the host wall clock`, `timeout enforces a host wall-clock deadline`, `options: timeoutMs bounds a pending async builtin`; native unit coverage verifies the non-JS clamp. | **MITIGATED** | | TM-DOS-058 | Single-builtin unbounded output | `seq 1 1000000` produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648) | `ExecutionLimits::max_stdout_bytes` and `max_stderr_bytes` truncate captured output (defaults set in `ExecutionLimits::new()`); see #648 | **MITIGATED** | | TM-DOS-059 | Parameter expansion replacement bomb | `${x//a/$(printf 'b%.0s' {1..1000})}` on large `x` amplifies output multiplicatively (10K × 1K = 10MB) | `max_total_variable_bytes` + `max_stdout_bytes` | **MITIGATED** | | TM-DOS-060 | Sparse array huge-index allocation | `arr[999999999]=x` could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB | HashMap-based arrays; `max_array_entries` caps total entries | **MITIGATED** | diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 858c3d51e..07396e4b8 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -94,7 +94,7 @@ Register a live byte command; requires native async execute(). ### `execute` ```python -Bash.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None) -> ExecResult +Bash.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -103,6 +103,7 @@ Execute bash commands asynchronously. - **`commands`** — Bash script to run (like ``bash -c "commands"``). - **`on_output`** — Optional callback receiving chunked ``(stdout, stderr)`` pairs during execution. Must be synchronous. +- **`timeout_seconds`** — Optional wall-clock cap for this call only. It can only tighten the constructor ``timeout_seconds``, never extend it. Async ``custom_builtins`` callbacks run on the caller's active asyncio loop. @@ -122,7 +123,7 @@ world ### `execute_sync` ```python -Bash.execute_sync(commands: str, on_output: OutputHandler | None = None) -> ExecResult +Bash.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute bash commands synchronously (blocking). @@ -147,7 +148,7 @@ Example: ### `execute_or_throw` ```python -Bash.execute_or_throw(commands: str, on_output: OutputHandler | None = None) -> ExecResult +Bash.execute_or_throw(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute commands asynchronously; raise ``BashError`` on non-zero exit. @@ -169,7 +170,7 @@ BashError: ... ### `execute_sync_or_throw` ```python -Bash.execute_sync_or_throw(commands: str, on_output: OutputHandler | None = None) -> ExecResult +Bash.execute_sync_or_throw(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute commands synchronously; raise ``BashError`` on non-zero exit. @@ -668,7 +669,7 @@ Register a live byte command; requires native async execute(). ### `execute` ```python -BashTool.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None) -> ExecResult +BashTool.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -690,7 +691,7 @@ True ### `execute_sync` ```python -BashTool.execute_sync(commands: str, on_output: OutputHandler | None = None) -> ExecResult +BashTool.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute bash commands synchronously (blocking). @@ -715,7 +716,7 @@ Example: ### `execute_or_throw` ```python -BashTool.execute_or_throw(commands: str, on_output: OutputHandler | None = None) -> ExecResult +BashTool.execute_or_throw(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute commands asynchronously; raise ``BashError`` on non-zero exit. @@ -734,7 +735,7 @@ True ### `execute_sync_or_throw` ```python -BashTool.execute_sync_or_throw(commands: str, on_output: OutputHandler | None = None) -> ExecResult +BashTool.execute_sync_or_throw(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult ``` Execute commands synchronously; raise ``BashError`` on non-zero exit.